MercadoFresco's photos are already in S3, with their lifecycle and their versioning. But the heart
of the business is still on the office server: the orders database. That is where the customers,
the products, the baskets, the delivery routes and the invoicing live. It is the data that
cannot be lost, the data that cannot be down on a Friday at 19:00, and the data that today is backed
up with a pg_dump to an external disk nobody has ever tried to restore.
This lesson migrates that database to Amazon RDS (Relational Database Service), AWS's managed relational database service. It is not just "PostgreSQL in the cloud": it is delegating patching, backups, failover and replication to AWS, tasks that at MercadoFresco nobody did because nobody had the time. And with automated backups and point-in-time recovery we finally close problem 2, the part that was left open in 02-02 when we solved the backups of the disks but not those of the transactional data.
Contents
- What a managed service is and what stops being your problem
- RDS versus PostgreSQL installed on an EC2
- Available engines and which one MercadoFresco chooses
- Creating the RDS instance from the console
- Creating the RDS instance from the CLI
- Multi-AZ: what a synchronous standby replica really is
- Read replicas: Sara's reports without punishing production
- Automated backups, retention and point-in-time recovery
- Manual snapshots and closing problem 2
- Parameter groups and maintenance windows
- Monitoring: basic metrics and Performance Insights
- Connecting from the application with no credentials in the code
- Vertical scaling and auto-scaling storage
- Migrating from the office server
- Costs and how to stop a test instance
What a managed service is and what stops being your problem
On the office server, PostgreSQL was entirely Marta's responsibility: installing it, configuring it, upgrading it, watching it, backing it up and bringing it back when it fell over. In RDS, AWS takes on a specific and well-delimited part of that work.
| Task | Office server | PostgreSQL on EC2 | Amazon RDS |
|---|---|---|---|
| Buying and maintaining hardware | Marta | AWS | AWS |
| Installing the operating system | Marta | You | AWS |
| Patching the operating system | Marta (never) | You | AWS |
| Installing PostgreSQL | Marta | You | AWS |
| Applying PostgreSQL patches | Marta (never) | You | AWS (in the maintenance window) |
| Configuring automated backups | Marta (half-heartedly) | You | AWS |
| Testing the restore | Nobody | You | AWS provides PITR |
| Setting up high availability | Impossible | You (complex) | AWS (one checkbox) |
| Automatic failover | Does not exist | You | AWS (1-2 minutes) |
| Creating read replicas | Very laborious | You | AWS (one command) |
| Encryption at rest | No | You | AWS (one checkbox) |
| Designing the schema and the queries | Marta and Luis | You | You |
| Optimising indexes | Marta and Luis | You | You |
| Application security | Marta and Luis | You | You |
It is the shared responsibility model from lesson 01-01 applied to one specific service: the line moves upwards, but it never disappears. RDS is not going to fix a query with no index, nor is it going to design your tables.
What RDS does not let you do, worth knowing before you choose it:
- There is no access to the operating system. No SSH to the database machine. If your operating procedures depend on scripts on the server, they have to be rethought.
- There is no real superuser. The master user has the
rds_superuserrole, powerful but limited: it cannot do everything a PostgreSQLsuperuserdoes. - Extensions are on an allowlist. You can only install the ones AWS supports (which are many: PostGIS, pg_stat_statements, pgvector…).
- Versions have an end-of-support calendar and AWS will eventually upgrade you.
RDS versus PostgreSQL installed on an EC2
Luis's legitimate question is: "we could install PostgreSQL on an EC2 and it comes out cheaper, couldn't we?". The honest answer is that the hourly price is lower, and the total cost almost never.
| Criterion | PostgreSQL on EC2 | Amazon RDS |
|---|---|---|
| Cost per hour | Lower (just the instance + EBS) | ~25-40 % dearer for the managed service |
| Time to get it running | Hours or days | 10 minutes |
| High availability | Setting up Patroni/repmgr by hand | Multi-AZ checkbox |
| Backups | Your own scripts, which you have to maintain | Automated, with PITR to the second |
| Patches | Your calendar, your responsibility | Maintenance window |
| OS access | Yes, full control | No |
| Exotic extensions | Any | Only the supported ones |
| Very old or very new versions | Any | Whatever AWS offers |
| Staff cost | High and continuous | Low |
Choose EC2 only if you need control of the operating system, an unsupported extension, a specific version outside the catalogue, or if you have a team of database administrators already doing that work. For MercadoFresco, which has Marta part-time for everything, RDS is not an option: it is the only sensible option.
The calculation that settles the argument: a db.t3.small in eu-west-1 costs around 30 USD a
month against some 20 USD for the equivalent EC2. Ten dollars of difference. A single night-time
PostgreSQL incident a year, with Marta awake at 03:00, costs more than that.
Available engines and which one MercadoFresco chooses
| Engine | When you choose it |
|---|---|
| PostgreSQL | Open standard, very complete, extensions (PostGIS, pgvector). MercadoFresco's choice |
| MySQL | Enormous installed base, legacy PHP applications |
| MariaDB | Fork of MySQL with a fully free licence |
| Oracle | Migrations of existing enterprise systems; expensive licence |
| SQL Server | Microsoft ecosystem; licence included or bring your own |
| Db2 | Legacy IBM systems |
| Aurora (PostgreSQL/MySQL compatible) | AWS's own engine, up to 5× MySQL and 3× PostgreSQL, storage distributed across 3 AZs. Covered in lesson 06-03 |
MercadoFresco was already using PostgreSQL on the office server, so RDS PostgreSQL allows the migration without rewriting queries. It is the lowest-risk decision.
One clarification that saves confusion: Aurora is also RDS, it is managed from the same console and shares almost all the operational work we will see here, but its storage architecture is different. In 06-03 we will weigh up whether it pays for MercadoFresco to migrate to Aurora when it opens in three more cities. And the general choice between relational, key-value or column store is lesson 06-01.
Creating the RDS instance from the console
- Console → RDS → check the region is Ireland (
eu-west-1) → Create database. - Method: Standard create (the easy one hides options you need to see).
- Engine: PostgreSQL, version 16.x.
- Template: Production turns on Multi-AZ and provisioned storage by default. To learn, choose Free tier so you spend nothing; we describe the production one here.
- Settings:
- Identifier:
mercadofresco-pedidos - Master user:
mfadmin(do not usepostgresoradmin) - Password: tick Manage master credentials in AWS Secrets Manager. With this the password is never seen by you, never typed by you, and AWS rotates it on its own. Secrets Manager is lesson 04-03.
- Identifier:
- Instance configuration:
db.t3.small(2 vCPU, 2 GiB). For MercadoFresco's real production,db.m6g.large. - Storage: gp3, 20 GiB, with storage autoscaling enabled and a maximum of 100 GiB.
- Availability: Multi-AZ standby instance. It is the checkbox that solves half of problem 1 for the database.
- Connectivity: default VPC, public access = No, security group
sg-mercadofresco-basedatos. Networking is module 3 (03-01 VPC, 03-02 security groups). - Authentication: password; optionally IAM authentication, which does away with passwords.
- Additional configuration:
- Initial database name:
pedidos - Automated backups: 7 days of retention
- Backup window: 02:00-03:00 UTC (the small hours in Spain, minimum traffic)
- Maintenance window: Sunday 04:00-05:00 UTC
- Encryption enabled
- Deletion protection: enabled
- Performance Insights: enabled (7 days are free)
- Initial database name:
- Review the monthly cost estimate the console shows and click Create database.
Creation takes between 5 and 15 minutes (longer with Multi-AZ, because it builds two instances).
Cost warning. A single-AZ
db.t3.microfalls within the Free Tier for 12 months (750 h/month, 20 GB of storage and 20 GB of backups). Multi-AZ doubles the compute cost and is not in the Free Tier. If you are practising, create the instance without Multi-AZ and tick the box just for a while to see how it works. The final section explains how to stop it and delete it.
Creating the RDS instance from the CLI
# 1. Subnet group: tells RDS which subnets it may place the instance in.
# There must be at least TWO, in TWO different AZs: that is the Multi-AZ requirement.
aws rds create-db-subnet-group \
--db-subnet-group-name sng-mercadofresco \
--db-subnet-group-description "MercadoFresco private subnets in two AZs" \
--subnet-ids subnet-aaa11111 subnet-bbb22222 \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=pedidos Key=Propietario,Value=marta \
Key=CentroCoste,Value=operaciones \
--profile mercadofresco-dev --region eu-west-1
# 2. The instance
aws rds create-db-instance \
--db-instance-identifier mercadofresco-pedidos \
--db-instance-class db.t3.small \
--engine postgres \
--engine-version 16.3 \
--master-username mfadmin \
--manage-master-user-password \
--allocated-storage 20 \
--max-allocated-storage 100 \
--storage-type gp3 \
--storage-encrypted \
--db-subnet-group-name sng-mercadofresco \
--vpc-security-group-ids sg-0abc123def456 \
--no-publicly-accessible \
--multi-az \
--backup-retention-period 7 \
--preferred-backup-window "02:00-03:00" \
--preferred-maintenance-window "sun:04:00-sun:05:00" \
--enable-performance-insights \
--performance-insights-retention-period 7 \
--deletion-protection \
--db-name pedidos \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=pedidos Key=Propietario,Value=marta \
Key=CentroCoste,Value=operaciones \
--profile mercadofresco-dev --region eu-west-1
# 3. Wait until it is available (blocks until it finishes)
aws rds wait db-instance-available \
--db-instance-identifier mercadofresco-pedidos \
--profile mercadofresco-dev --region eu-west-1
# 4. Get the endpoint the application will connect to
aws rds describe-db-instances \
--db-instance-identifier mercadofresco-pedidos \
--query 'DBInstances[0].{Endpoint:Endpoint.Address,Port:Endpoint.Port,
Status:DBInstanceStatus,MultiAZ:MultiAZ,AZ:AvailabilityZone}' \
--output table \
--profile mercadofresco-dev --region eu-west-1The parameters that matter most:
--manage-master-user-password: AWS generates the password, stores it in Secrets Manager and rotates it automatically. You never type it and never see it. It is the current best practice and it avoids the classic--master-user-password "Password123"in your shell history.--no-publicly-accessible: the database has no public IP. It is only reachable from inside the VPC. Non-negotiable for customer data.--max-allocated-storage 100: turns on storage autoscaling. If the disk reaches 90 % occupancy, RDS grows it on its own up to that ceiling.--deletion-protection: stops the instance being deleted by mistake. To really delete it you have to turn this off first, in a deliberate and separate step.
The endpoint will look like this:
It is a DNS name, not an IP, and that is fundamental. When a failover happens, AWS will change what that name points to and the application will reconnect to the new server without changing a single line of configuration. If anywhere in MercadoFresco's code there is a hand-written database IP address, it has to go now.
Multi-AZ: what a synchronous standby replica really is
There is a very widespread misunderstanding here that is worth clearing up plainly: the Multi-AZ standby instance does not serve traffic. It is not a second server sharing the load. You cannot connect to it. It is a synchronised copy waiting for the primary to fail.
flowchart TB
APP["MercadoFresco application<br/>storefront EC2 instances"]
EP["DNS endpoint<br/>mercadofresco-pedidos...rds.amazonaws.com"]
APP --> EP
EP --> P
subgraph AZ1["eu-west-1a"]
P["PRIMARY<br/>reads and writes"]
end
subgraph AZ2["eu-west-1b"]
S["STANDBY<br/>no traffic<br/>not reachable"]
end
P -->|"SYNCHRONOUS replication<br/>every commit is confirmed<br/>on both before answering"| S
P -.->|"automated backups<br/>are taken from the standby:<br/>no impact on production"| B["Backups in S3"]
How synchronous replication works: when the application commits a transaction, PostgreSQL writes to the primary and waits for the write to have been confirmed on the standby as well before answering "done" to the client. The consequence cuts both ways:
- Advantage: zero data loss (RPO = 0). What is committed is in both AZs.
- Cost: every write takes a little longer, because it includes a network round trip between AZs (typically 1-2 ms). On write-heavy workloads you notice it.
What exactly happens in a failover
- AWS detects the failure (of the instance, of the storage or of the whole AZ).
- It changes the endpoint's DNS record so that it points to the standby instance.
- The old standby becomes the primary.
- AWS creates a new standby in the other AZ, in the background.
Typical duration: 60-120 seconds. During that time the application gets connection errors. Multi-AZ is not invisible magic: it is fast recovery, not the absence of an interruption. The application has to be ready:
- Retries with exponential backoff on database operations.
- Low DNS TTL in the client's resolver. A connection pool that caches the IP forever will keep trying to connect to the dead server.
- A connection pool that knows how to discard dead connections (
pool_pre_pingin SQLAlchemy).
You can trigger a failover on purpose to check the application copes. Do it in a test environment, and do it before it happens without warning:
aws rds reboot-db-instance \
--db-instance-identifier mercadofresco-pedidos-pruebas \
--force-failover \
--profile mercadofresco-dev --region eu-west-1Multi-AZ versus read replica
They are different things and they are confused constantly:
| Multi-AZ (standby instance) | Read replica | |
|---|---|---|
| What it is for | High availability | Scaling reads |
| Replication | Synchronous | Asynchronous (there is lag) |
| Can you read from it? | No | Yes |
| Can you write to it? | No | No (unless you promote it) |
| Automatic failover | Yes | No (manual promotion) |
| Location | Another AZ in the same region | Same AZ, another AZ or another region |
| Number | 1 (or 2 with Multi-AZ cluster mode) | Up to 15 on PostgreSQL |
| Impact on write latency | Yes, it increases it | No |
| Cost | Doubles the compute | One extra instance per replica |
MercadoFresco needs both, and for different reasons: Multi-AZ because the business cannot lose orders, and read replicas because Sara's reports are killing the database.
Read replicas: Sara's reports without punishing production
The real, measured problem: every Monday morning Sara runs a query that aggregates the month's orders by district and by time slot. It takes 4 minutes and during that time the shop runs slowly, because the same server is serving the customers.
A read replica is an asynchronous copy of the database that accepts read-only queries. Writes still go to the primary; heavy reads are directed at the replica.
aws rds create-db-instance-read-replica \
--db-instance-identifier mercadofresco-pedidos-lectura \
--source-db-instance-identifier mercadofresco-pedidos \
--db-instance-class db.t3.small \
--availability-zone eu-west-1b \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=analitica Key=Propietario,Value=sara \
Key=CentroCoste,Value=marketing \
--profile mercadofresco-dev --region eu-west-1Look at the tagging: Componente=analitica, Propietario=sara, CentroCoste=marketing. The
replica exists for Sara, so its cost is charged to marketing and not to operations. This is exactly
what will make the cost allocation report in lesson 11-02 possible.
Points to be clear about:
- Replication is asynchronous: the replica runs a few seconds behind. Watch the
ReplicaLagmetric. For aggregated sales reports it is perfectly acceptable; for "check the status of the order I have just placed", it is not. - The application has to route the traffic. AWS gives you a different endpoint for the replica; the code decides which one it uses. RDS does not split it for you.
- A replica can be promoted to a standalone instance, which breaks replication irreversibly. It is a valid strategy for migrating between regions.
- They can be created in another region, which additionally gives geographic disaster recovery.
In MercadoFresco's code, the separation looks like this:
import os
import psycopg
# Two different connection strings, two different uses.
DSN_WRITE = os.environ["MF_DB_ESCRITURA"] # points to mercadofresco-pedidos
DSN_READ = os.environ["MF_DB_LECTURA"] # points to mercadofresco-pedidos-lectura
def create_order(cliente_id: int, lines: list) -> int:
"""Write: ALWAYS against the primary instance."""
with psycopg.connect(DSN_WRITE) as con, con.cursor() as cur:
cur.execute(
"INSERT INTO pedidos (cliente_id, creado_en) VALUES (%s, now()) RETURNING id",
(cliente_id,),
)
pedido_id = cur.fetchone()[0]
cur.executemany(
"INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad) VALUES (%s,%s,%s)",
[(pedido_id, l["producto_id"], l["cantidad"]) for l in lines],
)
con.commit()
return pedido_id
def sales_report_by_district(month: str) -> list:
"""Heavy analytical read: against the REPLICA, so the shop is not affected."""
with psycopg.connect(DSN_READ) as con, con.cursor() as cur:
cur.execute(
"""
SELECT c.barrio,
date_trunc('hour', p.creado_en) AS time_slot,
count(*) AS order_count,
sum(lp.cantidad) AS units
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
JOIN lineas_pedido lp ON lp.pedido_id = p.id
WHERE to_char(p.creado_en, 'YYYY-MM') = %s
GROUP BY c.barrio, time_slot
ORDER BY order_count DESC
""",
(month,),
)
return cur.fetchall()The query in plain SQL, so you can see it whole and understand why it is expensive: it walks every order in the month, joins them with customers and order lines, and aggregates by two dimensions. With hundreds of thousands of rows that is a long sequential scan that saturates the CPU and evicts hot data from PostgreSQL's cache. Exactly what you do not want while 900 customers are shopping.
Automated backups, retention and point-in-time recovery
This is where MercadoFresco's problem 2 is closed.
RDS does two things continuously and automatically:
- One full daily backup during the window you have set (02:00-03:00 UTC in our case). With Multi-AZ, it is taken from the standby instance, so production does not feel it.
- Continuous copying of the transaction logs (WAL) to S3, every 5 minutes.
The combination of the two is what makes point-in-time recovery (PITR) possible: you can restore the database to any second within the retention period, not just to the moment of the daily backup.
The scenario that justifies all of this: on Tuesday at 11:47, a deployment by Luis mistakenly runs
an UPDATE with no WHERE that sets the price of every product to zero. At 11:52 somebody
notices.
# See how recent a restore point is available
aws rds describe-db-instances \
--db-instance-identifier mercadofresco-pedidos \
--query 'DBInstances[0].LatestRestorableTime' \
--profile mercadofresco-dev --region eu-west-1
# Restore to 11:46, one minute before the disaster.
# IMPORTANT: it creates a NEW instance. The original is left untouched.
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier mercadofresco-pedidos \
--target-db-instance-identifier mercadofresco-pedidos-recuperada \
--restore-time 2026-08-04T11:46:00Z \
--db-subnet-group-name sng-mercadofresco \
--vpc-security-group-ids sg-0abc123def456 \
--no-publicly-accessible \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=pruebas \
Key=Componente,Value=pedidos Key=Propietario,Value=marta \
Key=CentroCoste,Value=operaciones \
--profile mercadofresco-dev --region eu-west-1The fact that the restore creates a new instance is deliberate and very useful: you can verify the data before touching production, or even extract just the affected table and copy it, instead of rolling the whole database back and losing the legitimate orders from those five minutes.
On retention:
| Retention | Effect |
|---|---|
| 0 days | Disables automated backups and PITR. Never in production |
| 1-7 days | The default (7). Enough for mistakes that are spotted quickly |
| 8-35 days | The maximum. For compliance requirements or slowly detected mistakes |
Automated backups are stored free up to the size of the database; beyond that they are charged at snapshot prices. And there is one critical detail: when you delete the instance, the automated backups are deleted with it. Only manual snapshots survive.
Manual snapshots and closing problem 2
A manual snapshot is a backup you create and that lives until you delete it, even if you delete the instance. It is the backup for milestones: before a schema migration, before a major version upgrade, at the end of the financial year.
# Before applying the schema migration for the Christmas campaign
aws rds create-db-snapshot \
--db-instance-identifier mercadofresco-pedidos \
--db-snapshot-identifier mf-pedidos-antes-migracion-navidad-2026 \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=pedidos Key=Propietario,Value=marta \
Key=CentroCoste,Value=operaciones \
--profile mercadofresco-dev --region eu-west-1
# Copy the snapshot to another region for disaster recovery
aws rds copy-db-snapshot \
--source-db-snapshot-identifier \
arn:aws:rds:eu-west-1:111122223333:snapshot:mf-pedidos-antes-migracion-navidad-2026 \
--target-db-snapshot-identifier mf-pedidos-dr-navidad-2026 \
--kms-key-id alias/aws/rds \
--profile mercadofresco-dev --region eu-central-1A comparison of the three forms of recovery in RDS:
| Automated backups (PITR) | Manual snapshot | AWS Backup | |
|---|---|---|---|
| Who creates them | RDS, daily | You | A centralised plan |
| Granularity | Any second | The instant of creation | Whatever the plan says |
| Retention | 0-35 days | Indefinite | Whatever you define |
| Survive deleting the instance | No | Yes | Yes |
| Cross-region copy | Not directly | Yes | Yes |
| Typical use | Recent human error | Milestones, long retention | Unified governance of every service |
And that closes MercadoFresco's problem 2, adding up what we did in 02-02 and in this lesson:
| Before (office server) | Now (AWS) | |
|---|---|---|
| Backup of the photos | Manual external disk | Automated EBS snapshots with DLM + S3 with versioning |
| Backup of the database | pg_dump when somebody remembered |
Daily automated + WAL every 5 min |
| Restoring to a specific moment | Impossible | PITR to any second in the last 7 days |
| Off-site copy | No | Snapshots replicated to eu-central-1 |
| Restore time | Hours, if the dump was any good | 10-20 minutes |
| Restore test | Never done | Quarterly, on a separate instance, without touching production |
With one warning that bears repeating: the quarterly restore test is part of the plan, not an extra. Marta has it in her calendar.
Parameter groups and maintenance windows
As there is no access to the operating system, you cannot edit postgresql.conf. Instead, RDS uses
parameter groups: a named set of settings associated with one or more instances.
The default group cannot be modified, so the first thing is to create one of your own:
aws rds create-db-parameter-group \
--db-parameter-group-name pg16-mercadofresco \
--db-parameter-group-family postgres16 \
--description "PostgreSQL 16 parameters for MercadoFresco" \
--profile mercadofresco-dev --region eu-west-1
aws rds modify-db-parameter-group \
--db-parameter-group-name pg16-mercadofresco \
--parameters \
"ParameterName=log_min_duration_statement,ParameterValue=1000,ApplyMethod=immediate" \
"ParameterName=shared_preload_libraries,ParameterValue=pg_stat_statements,ApplyMethod=pending-reboot" \
"ParameterName=log_connections,ParameterValue=1,ApplyMethod=immediate" \
--profile mercadofresco-dev --region eu-west-1
aws rds modify-db-instance \
--db-instance-identifier mercadofresco-pedidos \
--db-parameter-group-name pg16-mercadofresco \
--apply-immediately \
--profile mercadofresco-dev --region eu-west-1What we have configured and why:
log_min_duration_statement = 1000: logs every query taking more than 1 second. It is the most direct way to find the queries that sink the shop on Fridays.shared_preload_libraries = pg_stat_statements: turns on the extension that accumulates per-query statistics. The basis of any serious optimisation work.ApplyMethod:immediateis applied straight away;pending-rebootrequires restarting the instance. A dynamic parameter marked as pending is not doing anything yet, and this catches a lot of people out.
On the maintenance window: it is the weekly interval in which AWS applies operating system and engine patches. Choose it in the traffic trough (early on a Sunday morning for MercadoFresco, never a Friday afternoon). With Multi-AZ, maintenance is applied to the standby instance first, then the system fails over and only then is the other one patched: the interruption comes down to the failover time.
Minor version upgrades can be automatic (--auto-minor-version-upgrade); major version ones
(from PostgreSQL 16 to 17) never are: they require your explicit action and they have to be tested
first on an instance restored from a snapshot.
Monitoring: basic metrics and Performance Insights
RDS publishes metrics to CloudWatch without any configuration. The ones to watch:
| Metric | What it indicates | Suggested alarm threshold |
|---|---|---|
CPUUtilization |
CPU usage | > 80 % for 10 min |
DatabaseConnections |
Open connections | > 80 % of max_connections |
FreeableMemory |
Available memory | < 10 % of total RAM |
FreeStorageSpace |
Free disk space | < 10 % (or < 5 GB) |
ReadLatency / WriteLatency |
Disk latency | > 20 ms sustained |
ReplicaLag |
Read replica lag | > 30 s |
DiskQueueDepth |
Queued disk operations | > 5 sustained |
Performance Insights goes one step further: it is a dashboard showing the database load
broken down by query, by user, by host and by wait type. That last dimension is the one that
resolves incidents: it does not just tell you "the CPU is at 90 %", it tells you "70 % of the load
is waiting on locks on the pedidos table, and this is the query responsible".
The 7 days of retention are free and it is always worth turning on. Monitoring in depth, with alarms, dashboards and log aggregation, is lesson 05-01.
# Alarm on free space: the most predictable and most avoidable failure of all
aws cloudwatch put-metric-alarm \
--alarm-name mercadofresco-pedidos-espacio-bajo \
--alarm-description "Less than 5 GB free in the orders database" \
--namespace AWS/RDS --metric-name FreeStorageSpace \
--dimensions Name=DBInstanceIdentifier,Value=mercadofresco-pedidos \
--statistic Average --period 300 --evaluation-periods 2 \
--threshold 5000000000 --comparison-operator LessThanThreshold \
--alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
--profile mercadofresco-dev --region eu-west-1Connecting from the application with no credentials in the code
First, the manual check from one of the storefront EC2 instances (remember: the database is not public, so you have to connect from inside the VPC):
# Retrieve the AWS-generated password from Secrets Manager.
# It is never copied to a file nor written into your history.
export PGPASSWORD=$(aws secretsmanager get-secret-value \
--secret-id "rds!db-a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
--query 'SecretString' --output text \
--profile mercadofresco-dev --region eu-west-1 | python3 -c "import sys,json; print(json.load(sys.stdin)['password'])")
psql -h mercadofresco-pedidos.abc123xyz.eu-west-1.rds.amazonaws.com \
-U mfadmin -d pedidos -p 5432
unset PGPASSWORDOnce inside, some useful checks:
-- Engine version
SELECT version();
-- Size of the database
SELECT pg_size_pretty(pg_database_size('pedidos')) AS size;
-- The 5 slowest queries (requires pg_stat_statements to be enabled)
SELECT substring(query, 1, 80) AS statement,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
-- Active connections by state
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;And this is how the application connects, reading the secret at start-up and without a single credential touching the code or the repository:
"""
Connection from the MercadoFresco storefront to RDS PostgreSQL.
Credentials are read from Secrets Manager using the EC2 instance's IAM role:
no passwords in the code, in environment variables, or in the repository.
"""
import json
import os
import boto3
import psycopg
from psycopg_pool import ConnectionPool
REGION = "eu-west-1"
SECRET_ID = os.environ["MF_SECRETO_BD"] # only the identifier, not the key
WRITE_HOST = os.environ["MF_BD_HOST_ESCRITURA"]
READ_HOST = os.environ["MF_BD_HOST_LECTURA"]
def _credentials() -> dict:
"""Reads user name and password from Secrets Manager.
The IAM role of the EC2 instance (or of the Lambda) authorises this call:
no access key is needed. Detail in lessons 04-01 and 04-03.
"""
client = boto3.client("secretsmanager", region_name=REGION)
response = client.get_secret_value(SecretId=SECRET_ID)
return json.loads(response["SecretString"])
def _dsn(host: str) -> str:
c = _credentials()
# sslmode=require forces the connection to be encrypted in transit.
return (
f"host={host} port=5432 dbname=pedidos "
f"user={c['username']} password={c['password']} "
f"sslmode=require connect_timeout=5"
)
# Separate pools. min_size keeps connections open to avoid the cost
# of establishing them on every request during the Friday peak.
write_pool = ConnectionPool(_dsn(WRITE_HOST), min_size=2, max_size=10)
read_pool = ConnectionPool(_dsn(READ_HOST), min_size=1, max_size=5)
def order_status(pedido_id: int) -> dict | None:
"""A read-only query that MUST still go to the primary.
The customer has just placed the order: the replica's lag could
mean it is not found. Rule: whatever the user has just written,
is read from the primary.
"""
with write_pool.connection() as con, con.cursor() as cur:
cur.execute(
"SELECT id, estado, creado_en, entrega_estimada FROM pedidos WHERE id = %s",
(pedido_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"id": row[0],
"estado": row[1],
"creado_en": row[2].isoformat(),
"entrega_estimada": row[3].isoformat() if row[3] else None,
}The three rules this code sums up:
- No credential in the code or in the repository. Only the identifier of the secret.
- Authorisation by IAM role, not by access keys. The EC2 instance has a role that lets it read that one specific secret and nothing else.
sslmode=require: the connection is encrypted. RDS provides the certificate.
Vertical scaling and auto-scaling storage
Vertical scaling (changing the instance class). It is a disruptive operation, except with Multi-AZ, where AWS modifies the standby first, fails over and then does the other one: the outage comes down to the 60-120 seconds of the failover.
aws rds modify-db-instance \
--db-instance-identifier mercadofresco-pedidos \
--db-instance-class db.m6g.large \
--apply-immediately \
--profile mercadofresco-dev --region eu-west-1--apply-immediately applies the change right now, with the corresponding interruption. Without
that parameter, the change waits for the maintenance window, which is what you want in production
unless it is urgent.
Storage scaling. It can be grown at any time and without stopping, but not shrunk. With
--max-allocated-storage, RDS does it on its own:
aws rds modify-db-instance \
--db-instance-identifier mercadofresco-pedidos \
--allocated-storage 50 --max-allocated-storage 200 \
--apply-immediately \
--profile mercadofresco-dev --region eu-west-1When to scale what, in MercadoFresco's case:
| Symptom | Likely cause | Action |
|---|---|---|
| CPU at 90 % with slow queries | Not enough CPU or missing indexes | Look at the indexes first; then move up a class |
Low FreeableMemory, lots of disk reads |
The working set does not fit in RAM | A class with more memory (R family) |
| Lots of analytical reads | Sara's reports | A read replica, not a bigger instance |
FreeStorageSpace falling |
Normal growth | Storage autoscaling |
| High write latency | Insufficient IOPS | Raise gp3 IOPS or move to io2 |
The order matters: moving up in size is the last option, not the first. A missing index costs zero euros a month; a bigger instance class costs every month, for ever.
Migrating from the office server
With MercadoFresco's database (a few GB and an acceptable overnight maintenance window), the classic dump-and-restore migration is enough.
# ---------- On the office server ----------
# 1. Dump in "custom" format: compressed and restorable in parallel.
pg_dump -h localhost -U postgres -d pedidos \
--format=custom --compress=9 --verbose \
--file=pedidos-2026-08-04.dump
# 2. Check the dump before trusting it
pg_restore --list pedidos-2026-08-04.dump | head -20
ls -lh pedidos-2026-08-04.dump
# ---------- From an EC2 inside the VPC ----------
# 3. Upload the dump to S3 (private bucket, encrypted with KMS)
aws s3 cp pedidos-2026-08-04.dump \
s3://mercadofresco-copias-basedatos/copias/base-datos/ \
--profile mercadofresco-dev
# 4. Download it on the EC2 and restore against RDS.
# -j 4 restores with 4 parallel processes: much faster.
pg_restore -h mercadofresco-pedidos.abc123xyz.eu-west-1.rds.amazonaws.com \
-U mfadmin -d pedidos \
--no-owner --no-privileges --verbose -j 4 \
pedidos-2026-08-04.dumpWhy --no-owner --no-privileges: the roles and permissions of the source server do not exist in
RDS, and without those options the restore fills up with errors from trying to assign objects to
users that are not there.
Mandatory verification before switching the traffic over:
-- Compare the row counts of the main tables with the source
SELECT 'pedidos' AS table_name, count(*) FROM pedidos
UNION ALL SELECT 'clientes', count(*) FROM clientes
UNION ALL SELECT 'productos', count(*) FROM productos
UNION ALL SELECT 'lineas_pedido', count(*) FROM lineas_pedido;
-- Check that the indexes and the constraints have made it across
SELECT tablename, indexname FROM pg_indexes
WHERE schemaname = 'public' ORDER BY tablename;
-- Update the planner's statistics: without this, queries can run
-- absurdly slowly right after a restore.
ANALYZE VERBOSE;MercadoFresco's cut-over plan, early on a Sunday morning:
- 02:00 — Put the shop into maintenance mode (read only).
- 02:05 — Final
pg_dumpfrom the office server. - 02:20 —
pg_restoreinto RDS andANALYZE. - 02:40 — Verify the counts and run the smoke test suite.
- 02:50 — Point the application's environment variables at the new endpoint.
- 03:00 — Take off maintenance mode and watch the metrics for an hour.
- The office server is left switched on and in sync for a week, in case there is any need to go back.
When the shop cannot be stopped, the tool is AWS DMS (Database Migration Service): it copies the initial state and then replicates the changes continuously (change data capture), so that the cut-over window comes down to seconds. It can also migrate between different engines (from Oracle to PostgreSQL, for instance). For MercadoFresco it is overkill; for a migration with no downtime, it is the way.
Costs and how to stop a test instance
Components of the RDS bill:
| Component | Indicative price (eu-west-1) |
|---|---|
| Instance hours | db.t3.micro ≈ 0.018 USD/h · db.t3.small ≈ 0.036 · db.m6g.large ≈ 0.171 |
| Multi-AZ | ×2 on the instance hours |
| gp3 storage | ≈ 0.127 USD/GB-month |
| Backups | Free up to the size of the database; after that ≈ 0.105 USD/GB-month |
| Read replica | Like one more instance |
| Transfer between AZs | Free within Multi-AZ; chargeable out to the internet |
| Performance Insights | 7 days free; more retention, chargeable |
Monthly cost of MercadoFresco's production configuration:
db.t3.small instance Multi-AZ: 0.036 × 2 × 730 = 52.56 USD Storage 50 GB × 0.127 = 6.35 USD Backups (50 GB, within the free allowance) = 0.00 USD db.t3.small read replica = 26.28 USD -------------------------------------------------------------- Total ≈ 85.19 USD/month
Some €78 a month for a database with high availability, automated backups, 7-day PITR and a replica for analytics. Compared with the €6,000 of the original server — which had none of that — and with the time Marta spent maintaining it, the financial conversation becomes an easy one.
Stopping a test instance:
# Compute is stopped (billing stops); storage carries on costing.
aws rds stop-db-instance \
--db-instance-identifier mercadofresco-pedidos-pruebas \
--profile mercadofresco-dev --region eu-west-1
aws rds start-db-instance \
--db-instance-identifier mercadofresco-pedidos-pruebas \
--profile mercadofresco-dev --region eu-west-1Two important limitations: RDS only lets you keep an instance stopped for 7 days; on the eighth it starts it on its own. And a Multi-AZ instance cannot be stopped. For development environments only used during office hours, the practical answer is to schedule the start and the stop, or simply delete and recreate from a snapshot.
Deleting it completely when you finish the exercise:
# 1. Remove deletion protection (a deliberate, separate step)
aws rds modify-db-instance \
--db-instance-identifier mercadofresco-pedidos-pruebas \
--no-deletion-protection --apply-immediately \
--profile mercadofresco-dev --region eu-west-1
# 2. Delete the replicas first
aws rds delete-db-instance \
--db-instance-identifier mercadofresco-pedidos-lectura \
--skip-final-snapshot \
--profile mercadofresco-dev --region eu-west-1
# 3. Delete the primary, keeping a final snapshot just in case
aws rds delete-db-instance \
--db-instance-identifier mercadofresco-pedidos-pruebas \
--final-db-snapshot-identifier mf-pedidos-final-$(date +%Y%m%d) \
--profile mercadofresco-dev --region eu-west-1
# 4. Manual snapshots carry on costing. Review them and delete any you do not need.
aws rds describe-db-snapshots --snapshot-type manual \
--query 'DBSnapshots[].{ID:DBSnapshotIdentifier,GB:AllocatedStorage,Date:SnapshotCreateTime}' \
--output table \
--profile mercadofresco-dev --region eu-west-1Common Mistakes and Tips
- Believing the Multi-AZ standby serves reads. It does not. That is what read replicas are for. It is the number one misunderstanding about RDS.
- Setting
--publicly-accessibleso you "can connect from home". It exposes the database to the internet. The correct way is a bastion, Session Manager or a VPN. - Writing the password into the command or into the code. Use
--manage-master-user-passwordand Secrets Manager (04-03). - Backup retention set to 0 days. It disables PITR. It is the setting that turns a recoverable human error into a permanent loss.
- Forgetting that automated backups are deleted along with the instance. Before deleting, take a final snapshot.
- Changing a
pending-rebootparameter and expecting it to act. It does nothing until a restart. - Restoring and not running
ANALYZE. With no statistics the planner makes dreadful decisions and it looks as if RDS "is slow". - Putting the maintenance window at peak time. Early on a Sunday morning, never on a Friday afternoon.
- Moving up an instance class before looking at the queries. A missing index costs 0 USD a month; a bigger instance, every month.
- Reading from the replica what the user has just written. The asynchronous lag will sometimes mean it does not show up. Critical reads always go to the primary.
- Never testing a failover. Trigger one in a test environment and check the application reconnects. Finding out in production is expensive.
- Tip: turn on Performance Insights from day one. It is free for 7 days and the day there is an incident you will want the history.
Exercises
Exercise 1: designing the database topology
MercadoFresco opens in three more cities (problem 3). The forecast is:
- The Friday peak goes up to 2,100 orders/hour (write intensive).
- Sara goes from a weekly report to a dashboard that refreshes every 15 minutes.
- A new requirement appears: if the whole
eu-west-1region is lost, the business must be able to operate again in under 4 hours. - The requirement to lose no confirmed order still stands.
Design the RDS topology: which instances, of what type, in which AZs and regions, and what mechanism covers each requirement. State which requirement RDS cannot cover on its own.
Exercise 2: recovering from a disaster with PITR
On Thursday at 16:12, a catalogue synchronisation script mistakenly runs:
It has deleted 47,000 lines of historical orders that were in fact valid. Nobody notices until Friday at 10:30, when Sara sees that July's figures do not add up. In that time some 1,400 new orders have been recorded that cannot be lost.
Describe the complete recovery procedure, with the commands, and explain why you cannot simply restore the database to Thursday at 16:11.
Exercise 3: deciding where each query goes
For each MercadoFresco query, state whether it should go to the primary instance or to the read replica, and justify it in one sentence:
- A)
INSERTof a new order from the basket. - B) "My orders" for a customer who bought 3 seconds ago.
- C) Monthly report of sales aggregated by district.
- D) Listing the product catalogue on the home page.
- E) Stock check right before confirming an order.
- F) Overnight export of every order to S3 for analytics.
Solutions
Solution 1.
| Requirement | Solution | Detail |
|---|---|---|
| 2,100 orders/h of writes | Vertical scaling of the primary to db.m6g.xlarge (or migration to Aurora, lesson 06-03) |
Writes cannot be spread across replicas: there is only one writer. It is the limit of the classic relational model |
| Lose no confirmed order | Multi-AZ across eu-west-1a / eu-west-1b |
Synchronous replication: RPO = 0 |
| Sara's dashboard every 15 min | Read replica db.r6g.large in eu-west-1b |
R family because it is a memory-intensive analytical workload; a lag of seconds is irrelevant for a 15-minute dashboard |
| Surviving the loss of the region | Read replica in eu-central-1 + automated snapshots copied to that region |
In a disaster the replica is promoted to a standalone instance: minutes, well under the 4 hours required |
The resulting topology:
eu-west-1a: mercadofresco-pedidos (primary, db.m6g.xlarge) eu-west-1b: Multi-AZ standby instance (automatic, not reachable) eu-west-1b: mercadofresco-pedidos-lectura (replica, db.r6g.large) → Sara eu-central-1: mercadofresco-pedidos-dr (cross-region replica) → disaster recovery
What RDS does not cover on its own: the Friday write peak still depends on a single writer instance. Scaling vertically has a ceiling. The real solutions lie outside this lesson: queueing the orders with SQS to absorb the peak and write them at a sustained rate (module 7), caching the hot reads with ElastiCache (06-05), or spreading the writes with a different data model (DynamoDB, 06-02). Recognising that ceiling is part of knowing how to use RDS.
Solution 2.
Why you cannot simply restore to Thursday at 16:11: that would return the database to the state of that instant, and with it the 1,400 orders recorded between Thursday afternoon and Friday morning would disappear. You would fix one problem by creating a worse one. A full restore is only worth it when the damage is spotted within minutes.
The correct procedure is to restore in parallel and recover only what was deleted:
# 1. Restore to a NEW instance at the instant before the deletion.
# Production carries on working none the wiser.
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier mercadofresco-pedidos \
--target-db-instance-identifier mercadofresco-pedidos-rescate \
--restore-time 2026-08-06T16:11:00Z \
--db-subnet-group-name sng-mercadofresco \
--vpc-security-group-ids sg-0abc123def456 \
--no-publicly-accessible \
--db-instance-class db.t3.small \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=pruebas \
Key=Componente,Value=pedidos Key=Propietario,Value=marta \
Key=CentroCoste,Value=operaciones \
--profile mercadofresco-dev --region eu-west-1
aws rds wait db-instance-available \
--db-instance-identifier mercadofresco-pedidos-rescate \
--profile mercadofresco-dev --region eu-west-1# 2. Extract ONLY the deleted rows from the rescue instance.
pg_dump -h mercadofresco-pedidos-rescate.abc123.eu-west-1.rds.amazonaws.com \
-U mfadmin -d pedidos \
--table=lineas_pedido --data-only --format=custom \
--file=lineas-rescate.dump-- 3. In PRODUCTION: load the dump into a temporary table and reinsert
-- only what is missing. ON CONFLICT protects against duplicating anything.
CREATE TABLE lineas_pedido_rescate (LIKE lineas_pedido INCLUDING ALL);
-- (here the dump is restored into lineas_pedido_rescate)
BEGIN;
INSERT INTO lineas_pedido
SELECT r.*
FROM lineas_pedido_rescate r
LEFT JOIN lineas_pedido a ON a.id = r.id
WHERE a.id IS NULL
ON CONFLICT (id) DO NOTHING;
-- Verify BEFORE committing
SELECT count(*) FROM lineas_pedido; -- it should have gone up by ~47,000
COMMIT;
DROP TABLE lineas_pedido_rescate;# 4. Delete the rescue instance so you stop paying for it
aws rds delete-db-instance \
--db-instance-identifier mercadofresco-pedidos-rescate \
--skip-final-snapshot \
--profile mercadofresco-dev --region eu-west-1The lessons the incident leaves behind: the DELETE should have been run inside a transaction with
the count verified beforehand; maintenance scripts should not use the master user but a role with
narrow permissions (04-01); and an alarm on sudden variations in row counts would have warned on
Thursday and not on Friday.
Solution 3.
| Query | Destination | Justification |
|---|---|---|
A) INSERT of an order |
Primary | It is a write. Replicas are read-only, there is no alternative |
| B) "My orders" after buying | Primary | The asynchronous lag could mean the just-created order did not appear. Rule: whatever the user has just written is read from the primary |
| C) Monthly report by district | Replica | A heavy query over historical data; a few seconds of lag are irrelevant and this way it does not punish the shop |
| D) Catalogue on the home page | Replica (plus a cache) | Data that changes little and is read a great deal. The textbook case for a replica; with ElastiCache in front it would be better still (06-05) |
| E) Stock before confirming | Primary | Critical and volatile data: reading stale stock leads to selling product that does not exist. It also usually needs a lock (SELECT ... FOR UPDATE), which only works on the primary |
| F) Overnight export to S3 | Replica | A bulk read in the quiet hours; it is done on the replica precisely so as not to touch the primary even at night, when the backups run |
The general pattern: writes and critical or immediate reads → primary; analytical, historical or lag-tolerant reads → replica.
Conclusion
MercadoFresco's orders database is now in AWS, and with it the heart of the business. You understand what a managed service means: AWS takes on the hardware, the operating system, the engine patches, the backups and the failover; you remain responsible for the schema, the indexes, the queries and the security of the application. You also know what you give up in exchange — operating system access, a real superuser, extensions outside the list — and in which specific cases that forces you to choose PostgreSQL on EC2 instead of RDS.
You have created the mercadofresco-pedidos instance from the console and from the CLI, with the
decisions that matter taken deliberately: password managed in Secrets Manager so it is never
typed, no public access, encryption enabled, deletion protection, storage autoscaling, 7 days of
backup retention, and backup and maintenance windows placed in the traffic trough and never on a
Friday afternoon.
You have undone the most common misunderstanding about RDS: the Multi-AZ standby does not serve traffic. It is a synchronous replica that guarantees zero data loss and fails over in 60-120 seconds by changing the endpoint's DNS, with the trade-off of adding latency to every write and of demanding that the application retries. Against it, read replicas are asynchronous, can indeed be queried and are the right tool for keeping Sara's dashboard from punishing production; you have seen in code and in a table which query goes where.
And you have closed MercadoFresco's problem 2. Between the EBS snapshots automated with DLM in
lesson 02-02, the S3 versioning of 02-03 and now RDS's automated backups with transaction logs
every five minutes, the company has gone from "a pg_dump to an external disk when somebody
remembers" to being able to restore to any second in the last seven days. You have practised the
real recovery of a mass deletion by restoring in parallel and reinserting only what was lost,
without sacrificing the orders that came afterwards. To that you add the manual snapshots that
survive deleting the instance, the parameter groups with log_min_duration_statement and
pg_stat_statements for hunting slow queries, Performance Insights for knowing what the
database is really waiting on, connecting from the application without a single credential in the
code, vertical and storage scaling with its golden rule — look at the indexes before moving up a
class — and the complete migration plan with pg_dump/pg_restore and its no-downtime alternative,
AWS DMS.
One last piece of the module remains. We have servers that have to be sized, started and paid for even when they are idle. But there are MercadoFresco tasks that do not need a server sitting there waiting: generating the thumbnail of a photo when it is uploaded, answering a one-off query about the status of an order, processing a file. In lesson 02-05, "AWS Lambda", we will write code that runs only when something happens and is only paid for while it runs, we will finally connect the S3 event we left configured in 02-03 to generate the catalogue thumbnails, and we will recap which pieces of MercadoFresco are already in the cloud and what is left to build.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
