Migrating from AWS RDS to Self-Hosted PostgreSQL: A Complete Plan, Cutover Method and Checklist
How to move a small SaaS stack (Fargate containers, RDS, ElastiCache, NAT gateways) to a hardened Docker host with PostgreSQL you operate yourself, with minutes of downtime and a real way back.
By Subeesh Sivanandan, Founder & CEO, Stonetusker Systems · Last reviewed October 2026 · Est. reading time: 39 minutes
This guide uses a worked example, not a client case. The stack, sizes, prices and timings are planning assumptions for a typical small SaaS stack. Commands and settings were written for PostgreSQL 16, so check them against the current PostgreSQL, pgBackRest and AWS documentation for your versions, and rehearse everything on a copy before it touches production.
Executive brief
- Who this is for. A steady, database-heavy workload on AWS, with at least one person (or a partner) comfortable running Linux and PostgreSQL. It is a poor fit for spiky workloads that need real elasticity, teams with nobody willing to carry the pager, and contracts or audits that require a managed database service.
- If one over-provisioned RDS instance is most of your AWS bill and the rest of the workload is a few small containers, moving to one or two dedicated servers running PostgreSQL, Docker Compose and a reverse proxy can cut the infrastructure bill sharply. The worked example below assumes a 60 to 90 percent planning range, which only your own cost data can confirm. You then own patching, backups, failover and on-call.
- Do an honest right-sizing pass first. A smaller or reserved RDS instance, one NAT gateway instead of two, and storage tuning sometimes close enough of the gap that migrating isn't worth the risk. The assessment phase exists to answer that question with numbers.
- For a database in the low hundreds of gigabytes, native PostgreSQL logical replication from RDS to the new server gives a cutover measured in minutes, not hours. Dump and restore is simpler but means hours of downtime.
- Self-hosting is only safe if backups are real: pgBackRest with continuous WAL archiving to off-site object storage, a tested restore, and an alert that fires when either breaks. All of that must exist before production data lands on the new box.
- In an illustrative worked example, a 5,000 USD per month bill drops by roughly 64 percent net of operations time, with payback in about 11 months. Against a right-sized AWS baseline the extra return is smaller, which is why the assessment comes first.
- Budget for what the price page hides: on-call time, egress during the sync, double running during the parallel period, and a performance baseline so you can tell whether the new server is actually faster or slower.
- Keep AWS running as a rollback target for two to four weeks, and set up reverse replication at cutover so a rollback doesn't lose the writes made after the move.
Why teams leave managed databases, and who shouldn't
For about a decade the standard advice has been to let someone else run your database, and early on it's the right call. Nobody on a five-person team wants to be the DBA. RDS hands you backups, Multi-AZ failover and patching in a few clicks, and the premium is small next to the cost of an outage that nobody knows how to debug.
The math changes as a product settles down. Managed pricing charges a markup on RAM, provisioned IOPS and storage compared with raw hardware, and the plumbing around it adds its own line items: NAT gateways, load balancers, cross-AZ traffic, egress. When the workload is steady and the database is the biggest number on the invoice, you're paying a flexibility premium for flexibility you aren't using.
Tooling has moved too. pgBackRest, native logical replication and cheap NVMe servers mean a small team can run PostgreSQL properly without a dedicated DBA. "Properly" is doing real work in that sentence: tested restores, monitoring that pages someone, and a patching routine. Skip those and you've traded a bill for a risk.
| Usually a good fit | Usually a poor fit |
|---|---|
| Steady, database-heavy workload with small compute needs | Spiky traffic that needs real elasticity or burst scaling |
| At least one person (or a partner) comfortable with Linux and PostgreSQL | Nobody willing to carry the pager |
| Database is the dominant cost and utilization is low | A database small enough that the whole bill is already modest |
| Tolerance for a minutes-long failover procedure | A contract or audit regime that requires a managed database service |
Should you migrate at all?
This article is a reference plan with a worked example, not a client story. The example is a generic, hypothetical stack: application containers on ECS Fargate, one large single-AZ RDS instance running PostgreSQL 16 with a few hundred GB of data, a managed cache, a pair of load balancers and NAT gateways, and a scheduled batch job, all managed with Terraform. The containers are small, yet most of the monthly bill is the database instance. Every figure below is an assumption you should replace with your own.
That ratio is the whole story. The compute is tiny and the database is oversized, and everything else is the usual AWS tax: NAT gateway hourly and per-GB charges, ALB fixed fees, and a serverless cache priced for spiky workloads this example team doesn't have.
Before anyone touches a server, run the comparison honestly. There are three realistic options.
| Option | What changes | Planning range (illustrative) | Risk and effort |
|---|---|---|---|
| A. Right-size AWS in place | Smaller RDS class (check CPU, IOPS and buffer cache hit data first), reserved instance or savings plan, one NAT gateway or VPC endpoints, trim the cache and ALBs | 30 to 55 percent of the bill | Low. Days of work, one short maintenance window |
| B. Self-host everything | Dedicated or VPS servers, PostgreSQL with a streaming standby, Compose stack, off-site backups | 60 to 90 percent | Medium. Plan on weeks, not days (about six in the example), and you now own operations |
| C. Hybrid | Move the database and cache off AWS, keep the email-sending service and DNS there (or at a neutral DNS host) | 55 to 85 percent | Medium. The app still runs on AWS while the database sits elsewhere, so cross-provider latency matters. In practice it usually folds into B |
These saving ranges are illustrative. Your own numbers come from Cost Explorer, CloudWatch metrics for the last 30 to 90 days, and the actual query workload. If the data says the instance is 15 percent utilized at peak, option A alone is a quick win and option B is the bigger, riskier step. If it says the instance is genuinely busy during search-heavy periods, sizing the replacement correctly matters more than the savings headline.
Can you state, from the last 30 days of metrics, your p95 CPU, peak IOPS, buffer cache hit ratio and peak connection count on the database right now? If not, you can't size either the right-sized RDS or the replacement server yet.
The right-size-first answer is a legitimate outcome of the assessment. A consultant who only ever recommends migrating is selling you a project, not a plan.
Stay managed (and right-size instead) if you run in a regulated environment where auditors expect a managed database service, if nobody can carry the pager, if your traffic is spiky enough to need real elasticity, or if the database is small enough that the bill is already modest.
Target architecture and sizing
For this workload, two servers in the same provider region but separate data centers (or at minimum separate racks) beat one big server. The first runs the application stack and the primary database. The second is a streaming replica that doubles as the restore-test target and the failover candidate.
A cleaner split is database on its own host, application containers on another. It costs a little more and removes the noisy-neighbor problem when the ETL job hammers the disks at 3 a.m. For a team this size, start with the split if the budget allows, and fall back to a single combined primary plus a standby if it doesn't.
How the pieces connect
Both replication directions run over the private tunnel described under "The network path for replication" below. The solid arrow from RDS carries the migration. After cutover the dashed path takes over as your rollback safety net, and the primary, standby and backup repository are the permanent design.
Specification, with reasoning
| Component | Starting specification | Why |
|---|---|---|
| Database primary | 8 to 16 cores, 64 GB RAM, 2 x NVMe in RAID 1 (about 1 TB usable) | The working set should sit mostly in RAM. a few hundred GB of data plus indexes and growth means 1 TB gives years of headroom and room for WAL |
| Database standby | Same disk and RAM as the primary, fewer cores is acceptable | Replica must hold the same data and keep up with WAL. Under-sizing disk is the classic mistake |
| App host | 4 to 8 vCPU, 16 GB RAM, 160 GB SSD | A few small containers, Redis, the batch job and any small legacy service fit comfortably. Spare capacity covers the staging run |
| Redis | Container on the app host, 1 to 2 GB maxmemory, allkeys-lru | If it is a pure cache, losing it costs a warm-up period and nothing else. Confirm that in the code first |
| Connection pooler | PgBouncer (transaction mode) as a systemd service on the DB host, from the PGDG packages | Keeps real backend connections to a few dozen and removes connection churn from the Node workers. Check the app for session-level features (prepared statements, advisory locks) before choosing the mode |
| Reverse proxy | Caddy or Traefik in Compose | Automatic Let's Encrypt certificates, HTTP/2, small config surface |
| Off-site backup | S3-compatible object storage at a different provider | A backup in the same account or data center as the data isn't a backup |
| Private network | Provider private network or WireGuard between hosts | PostgreSQL never listens on a public interface |
One design decision worth stating plainly: run PostgreSQL directly on the host from the PGDG packages, not inside the same Compose file as the app. Containerized Postgres works, and plenty of people run it that way. But a docker compose down typed on the wrong terminal shouldn't be able to stop your database, and host-level packages make upgrades and pgBackRest integration more predictable.
Failover: keep it manual and rehearsed
With two database nodes, resist the urge to add Patroni, repmgr or another automatic failover layer unless you have someone who will own it. Automatic tools need a proper consensus setup (a third node or witness) to avoid split-brain, and a badly tuned one will promote the standby over a network blip and leave you with two primaries. A written, timed, manual runbook is safer at this scale. Drill it and record the real time, because that number is your failover time:
- Confirm the primary is really down, then fence it: power it off or cut its network so it can't come back and accept writes.
- Promote the standby with
SELECT pg_promote();orpg_ctl promote. - Repoint the application: move the floating IP, update the DNS record, or change the PgBouncer target.
- Confirm WAL archiving and backups continue from the new primary (pgBackRest follows the new timeline).
- Rebuild the old primary as a standby with
pg_rewindor a pgBackRest delta restore.
Revisit automation when the team grows or the downtime tolerance shrinks. Until then, a drilled human beats an untested cluster manager.
Things to inspect before you size anything
Look at pg_stat_statements output on RDS (enable it if it isn't already) and rank queries by total time. Check the largest tables and indexes, write rate (WAL generated per hour), and whether any tables lack a primary key, because that matters for logical replication later. List installed extensions and compare against what you can install on the new host. Read the application's connection handling: pool size, idle timeouts, and whether anything uses long-lived transactions.
Provider and region
Pick the region by user and data location first, price second. If most of your traffic and your data subjects are in Europe, a European region keeps latency low and simplifies data protection. If your users are North American, say so and choose accordingly. API calls out to video and streaming platforms don't care much where you sit.
The criteria that matter more than raw price:
- Two data centers or availability zones in the same region, with a private network between them
- Dedicated NVMe storage (not network-attached volumes with burst credits) for the database
- Out-of-band access (rescue mode, KVM or console) for when SSH is the thing that's broken
- Hardware replacement SLAs you've actually read
- Honest egress pricing, since backups and replication move real data
- An S3-compatible object store from a different company for backups
Dedicated servers from a European bare-metal provider are the cheapest option per unit of performance and the one to price first for a database this size. Large VPS and mid-tier cloud providers cost more but give you faster provisioning and snapshots. Either is fine. Single-vendor lock-in for everything including backups isn't.
Server hardening and Docker setup
Hardening is boring and non-negotiable. Do it on day one, not after the first scary log line.
- SSH keys only:
PasswordAuthentication no,PermitRootLogin no, a named admin user with sudo, ideally a non-default port plus a bastion or VPN - Host firewall (nftables or ufw) with default deny inbound, allowing only 80, 443 and SSH from known ranges
- fail2ban watching sshd and the proxy logs
unattended-upgradesfor security patches, with automatic reboot windows for kernel updates on the standby first- Time sync (chrony), a swap policy, and
vm.swappinessset low on the DB host - Auditd or at least centralized auth logs shipped off the box
The Docker trap: published container ports bypass ufw rules by default because Docker writes its own iptables entries. Bind internal services to 127.0.0.1 or the private interface explicitly, and publish only the reverse proxy to the world. Check with an external port scan, not by trusting the firewall config.
services:
proxy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
restart: unless-stopped
api:
image: registry.example.com/app-api:${API_TAG}
env_file: [.env.api]
expose: ["3000"]
restart: unless-stopped
web:
image: registry.example.com/app-web:${WEB_TAG}
expose: ["80"]
restart: unless-stopped
redis:
image: redis:7
command: ["redis-server","--maxmemory","1gb","--maxmemory-policy","allkeys-lru"]
restart: unless-stopped
etl:
image: registry.example.com/app-etl:${ETL_TAG}
profiles: ["jobs"]
env_file: [.env.etl]
volumes:
caddy_data:Run the ETL job from a systemd timer using docker compose run --rm etl rather than a container that sleeps forever. You get logs in journald and a clear failure signal. Images move out of ECR into a registry you control (a self-hosted registry or a hosted one), pinned by tag or digest, and the old ECR repositories stay until decommission.
PostgreSQL tuning and the search workload
RDS parameter groups hide a lot of defaults. On your own server you set them deliberately. These starting values suit a 64 GB, NVMe-backed host and are a baseline to measure against, not a destination.
shared_buffers = 16GB
effective_cache_size = 44GB
work_mem = 32MB
maintenance_work_mem = 2GB
random_page_cost = 1.1
effective_io_concurrency = 200
wal_compression = on
max_wal_size = 16GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
max_connections = 200
shared_preload_libraries = 'pg_stat_statements'
wal_level = logical # needed during migration
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'If the API opens many connections, put PgBouncer in transaction mode in front of it and keep max_connections modest. Raising it to 1,000 to hide a pooling problem is how a server with plenty of RAM ends up swapping.
Filesystem and disk layout
Use XFS or ext4 on the NVMe volume and mount the data directory with noatime, so reads stop generating metadata writes. Keep the data and WAL on volumes that can't starve each other: with a two-drive RAID 1 pair you get most of the benefit from separate logical volumes or partitions, so a runaway WAL backlog can't fill the data volume. A physically separate pg_wal device only pays off if you have a third NVMe drive and heavy bulk-write bursts from the ETL job. Set the I/O scheduler to none or mq-deadline for NVMe, and check SMART health in monitoring.
The heavy search queries
A search-heavy product lives or dies on filtered, sorted, text-heavy queries across a huge table. Those are exactly the queries that behave differently on a new host, because planner statistics, cache warmth and storage latency all change. Capture the top 20 to 50 by total time from pg_stat_statements, replay them against the restored staging copy, and compare plans with EXPLAIN (ANALYZE, BUFFERS). Look at trigram (pg_trgm) GIN indexes for fuzzy name search, partial indexes on active rows, and whether the ETL's bulk writes are bloating things badly enough to need a more aggressive autovacuum setting on the biggest tables.
Run ANALYZE on the whole database after the initial copy. Logical replication does not carry planner statistics across, and a freshly loaded database with no stats picks bad plans.
Can you point to the five slowest queries in production by total time, and say what their plan and latency will be on the new server before cutover? If the first time you see those numbers is after the DNS change, you're testing in production.
Choosing the migration method
RDS doesn't give you physical replication access, so pg_basebackup and streaming replication from RDS to your server aren't options. That leaves three methods.
| Method | Downtime (example database) | Pros | Cons |
|---|---|---|---|
pg_dump / pg_restore (parallel) | Several hours (dump, transfer, restore, index builds) | Simplest, nothing to leave running, cleans up bloat | Long write freeze. Hard to justify for a live product |
| Native logical replication | Minutes (write freeze, lag drain, sequence sync, switch) | Same major version or newer, no extra service, built in, reversible | No DDL or sequence replication, needs primary keys, slot can bloat WAL on the source if it stalls |
| AWS DMS | Minutes | Handles heterogeneous sources, managed | Another service to configure and pay for, type-mapping surprises, overkill for Postgres to Postgres |
For PostgreSQL 16 to PostgreSQL 16, native logical replication is the usual default. The initial table copy of a database this size can take hours depending on bandwidth and index builds (measure it in staging), and then the new server tracks live changes until you're ready to switch. Documentation: PostgreSQL logical replication.
The network path for replication
Replication needs a route in both directions. The new server connects out to RDS to pull the initial copy and the live changes, and after cutover RDS connects out to the new primary for reverse replication. RDS usually sits in private subnets and the new server should never expose PostgreSQL publicly, so decide this path early.
- Preferred: a private tunnel. A site-to-site VPN or a WireGuard tunnel between the VPC and the new hosts keeps PostgreSQL on private addresses. Test it with
psqlfrom each side before touching replication. - Fallback: a time-boxed, allow-listed TLS path. Make the RDS endpoint reachable only from the new server's fixed IP addresses through its security group. On the new primary, allow only the RDS egress addresses through the host firewall and
pg_hba.conf(hostssl, a dedicated replication role,scram-sha-256). Remove the openings when the rollback window closes. - Check the egress route. If RDS reaches the internet through a NAT gateway, reverse replication depends on that route, so keep the NAT gateway until the rollback window is over. Confirm the actual route in your own VPC.
- Use a dedicated role. Give the replication user only what it needs. On RDS that means membership in the
rds_replicationrole plus read access to the tables. Check the current RDS and PostgreSQL documentation for the exact grants.
Setup outline
On RDS, set rds.logical_replication = 1 in the parameter group. That change needs a reboot, and on a single-AZ instance the reboot is an outage, so schedule it in a quiet window and tell people. Then create the schema on the target, publish, and subscribe.
# 1. Schema only, from RDS to the new server
pg_dump --schema-only --no-owner --no-privileges -h RDS_HOST -U admin appdb | psql -h NEW_HOST -U postgres appdb
# 2. On RDS: publish
CREATE PUBLICATION st_migrate FOR ALL TABLES;
# 3. On the new server: subscribe and copy
CREATE SUBSCRIPTION st_sub
CONNECTION 'host=RDS_HOST dbname=appdb user=repl_user sslmode=require'
PUBLICATION st_migrate;Two cautions. A long initial copy holds a snapshot on the source, which delays vacuum there and lets the replication slot retain WAL, so watch RDS free storage the entire time. And any table without a primary key or replica identity will fail on updates and deletes. Find those first with a catalog query and fix them on the source before you start.
Budget for the transfer too. AWS charges standard internet data transfer out of a region, around 0.09 USD per GB at common published rates (check current pricing for your region). A few-hundred-GB initial copy plus a few days of WAL streaming lands in the tens of dollars at that rate, which is trivial, but it's worth writing down so nobody is surprised by the line item.
Proving nothing was lost
Row counts alone aren't verification. Compare counts per table, then compare content hashes by key range for the large tables (for example, an MD5 over ordered rows in 100,000-id chunks, run on both sides). Spot-check recent writes by timestamp. Compare sequence values and set the target ahead of the source at cutover. Run pg_amcheck on the target for index integrity. Finally, run the application's own smoke tests and a read-only copy of real user flows against the new database.
Backups and tested restores
This is the section that decides whether self-hosting is responsible or reckless. Managed databases give you automated backups and point-in-time recovery whether you thought about them or not. On your own hardware, you build that, and you prove it works.
Use pgBackRest (official documentation) with a repository in an S3-compatible bucket at a different provider than your servers. A sensible schedule for this size:
- Full backup weekly, differential daily, with continuous WAL archiving so recovery to any second is possible
- Client-side encryption on the repository (
repo1-cipher-type=aes-256-cbc) with the key stored separately from the servers - Retention: two weeks of fulls, with WAL kept to match. Add a monthly full kept longer if your business needs it
- Backups taken from the standby when possible, to spare the primary
- Bucket versioning or object lock, so a compromised server can't delete its own backups
[global]
repo1-type=s3
repo1-s3-endpoint=objects.backup-provider.example
repo1-s3-bucket=app-pgbackrest
repo1-s3-region=eu-1
repo1-path=/main
repo1-retention-full=2
repo1-cipher-type=aes-256-cbc
process-max=4
start-fast=y
[main]
pg1-path=/var/lib/postgresql/16/mainThe restore test is the backup
An untested backup is a hope. Automate a restore into a scratch environment (a container or a small VM, or the standby host's spare disk) on a schedule: weekly is reasonable. The job restores the latest backup, replays WAL to a target time, starts Postgres, runs pg_amcheck, compares table counts against production, and reports how long it took. That duration is your real recovery time objective, and you need to know it before you need it.
Do one full manual restore drill before production data arrives, with someone who didn't set it up following the runbook. If they get stuck, the runbook is wrong.
If the primary server vanished right now, who restores what, from where, using which document, and how long would it take? If you can't answer in one breath, the backup design isn't finished.
Monitoring and alerting
CloudWatch told you things without being asked. Off AWS, you have to build the equivalent, and the failure mode to avoid is a pretty dashboard nobody looks at.
A compact stack that fits on the app host: Prometheus, Grafana, Alertmanager, node_exporter on every server, postgres_exporter, cAdvisor for containers, and blackbox probes for HTTPS endpoints and certificate expiry. Add one external uptime check from a different network, plus a dead-man's-switch alert so you find out if the monitoring itself dies.
| Alert | Condition | Severity |
|---|---|---|
| Disk space | Data or WAL volume above 80 percent, or projected full within 7 days | Page at 90 percent |
| Backup freshness | No successful backup in 26 hours, or WAL archiving failing | Page |
| Restore test | Weekly job failed or took more than twice its usual time | Ticket, then page if repeated |
| Replication | Standby lag above 60 seconds or replica disconnected | Page |
| Connections | Above 80 percent of max_connections | Warn |
| Transaction ID age | Approaching wraparound thresholds | Warn early, page late |
| Certificates | Fewer than 14 days to expiry | Warn |
| Application | 5xx rate or p95 latency over agreed thresholds | Page |
Alerts should go to a channel somebody owns and a paging path for the serious ones. Every alert needs a runbook link. If an alert has no obvious action, delete it.
The migration playbook
Plan on roughly six weeks for the example stack, with one person doing most of the work and the owning team reviewing. That is a planning assumption, so adjust it after the assessment and the staging rehearsal. Here's the order that avoids the usual surprises.
Assess and decide
Pull 90 days of cost and utilization data, review the Terraform and application code, inventory extensions and cron jobs, and price right-sizing against self-hosting. Deliver a written plan and let the decision-maker approve it before anything is bought.
Provision and harden
Order the servers, apply the hardening baseline, set up the private network and the tunnel to AWS, install Docker and PostgreSQL, and put everything in configuration management or at least a versioned repository.
Build backups and monitoring first
Configure pgBackRest, run the first backup and a full restore drill, then stand up Prometheus, Grafana and alerts. No production data lands before this step passes.
Stand up the application stack
Move images out of ECR, write the Compose files and reverse proxy configuration, wire secrets in through an encrypted store such as SOPS, and bring the stack up against an empty database.
Run a parallel staging copy on real data
Start logical replication, let the initial copy finish, run ANALYZE, then replay the heavy search queries and a traffic sample. Fix indexes and settings now, while nothing depends on it.
Rehearse the cutover
Do a full dry run on a throwaway subscription: freeze writes, drain lag, sync sequences, switch a test hostname. Time every step. Write the real run sheet from the rehearsal.
Cut over
Lower DNS TTLs a week ahead. In a low-traffic window, execute the run sheet, start reverse replication back to RDS, and watch error rates, latency, replication and backups for the first hours.
Run in parallel, then decommission
Keep AWS idle but intact for two to four weeks as the rollback target. When the window closes, take final snapshots, export what you need to keep, and tear down in the right order.
Cutover and rollback
The cutover sequence, once the subscription is caught up and rehearsed:
- Announce the window and put the app in maintenance mode, or stop the API and ETL containers on AWS so nothing writes.
- Confirm replication lag is zero on the subscription.
- Copy sequence values from RDS to the new server, adding a safety margin to each.
- Run the verification queries: counts, hashes on the hot tables, latest timestamps.
- Drop the subscription on the target. Promote the new database as the sole writer.
- Create a publication on the new primary and a subscription on RDS with
copy_data = false(the data is already identical), so AWS stays current. - Point DNS at the new proxy. Bring the application up on the new host.
- Smoke test, watch dashboards, announce completion.
The write freeze is the downtime. Its length is the sum of the lag drain, the sequence sync, the verification queries and the DNS switch, so the only honest number is the one you time in your own rehearsal. Set the maintenance window from that rehearsal, with margin. DNS propagation matters less than people fear if TTLs were lowered to 60 or 300 seconds earlier and you keep the old endpoint serving a maintenance page rather than live traffic.
A registrar's default DNS is often a poor place to run DNS for a cutover. Consider moving the zone to a provider with an API and fast propagation a couple of weeks before the migration, copying every record exactly, including the sending-domain verification and DKIM records so email keeps flowing. Leave the email-sending service where it is; it usually costs little and moving it adds risk for no gain.
Syncing sequences at cutover
Logical replication copies row data but not sequence positions, so every serial or identity column on the target would start from its old value. Run this after the write freeze, from RDS into the new server. The margin of 1,000 is arbitrary but safe. The WHERE clause skips sequences whose last_value is null. That value is null for a sequence that has never been used, and it can also be null when your role lacks privileges on the sequence (check the pg_sequences documentation for your version). Resetting a sequence like that to a low number would cause duplicate-key errors, so those are listed for a manual check instead.
# 1. Generate and apply the setval statements
psql -h RDS_HOST -U admin -d appdb -At -c "
SELECT format('SELECT setval(%L, %s);',
quote_ident(schemaname) || '.' || quote_ident(sequencename),
last_value + 1000)
FROM pg_sequences
WHERE last_value IS NOT NULL;" | psql -h NEW_HOST -U postgres -d appdb
# 2. List the sequences that were skipped
psql -h RDS_HOST -U admin -d appdb -At -c "
SELECT schemaname || '.' || sequencename
FROM pg_sequences
WHERE last_value IS NULL;"Review every skipped sequence by hand, and compare each sequence on the target with the highest id in its table before you accept the cutover. A rollback needs the same two steps run the other way, from the new primary into RDS.
Rollback
Define the rollback trigger before cutover, in writing: for example, error rate above a set percentage for 15 minutes, or data integrity doubts of any kind. Because reverse replication has kept RDS current, rollback means freezing writes on the new server, confirming RDS has caught up, switching DNS back, and starting the AWS containers. Test that path in the rehearsal, not just the forward path.
Reverse replication costs a small amount of complexity and removes the worst scenario: discovering a problem on day three and having to choose between losing three days of data and living with the problem.
Reverse replication gotchas
The reverse path is created at cutover, after the forward subscription has been dropped. On RDS, subscribe with copy_data = false, because the tables already hold identical data and a second copy would collide with the existing rows:
# On the new primary
CREATE PUBLICATION st_rollback FOR ALL TABLES;
# On RDS
CREATE SUBSCRIPTION st_rollback_sub
CONNECTION 'host=NEW_HOST dbname=appdb user=repl_user sslmode=require'
PUBLICATION st_rollback
WITH (copy_data = false);
# If you roll back: sync sequences the other way, from the new primary into RDS
psql -h NEW_HOST -U postgres -d appdb -At -c "
SELECT format('SELECT setval(%L, %s);',
quote_ident(schemaname) || '.' || quote_ident(sequencename),
last_value + 1000)
FROM pg_sequences
WHERE last_value IS NOT NULL;" | psql -h RDS_HOST -U admin -d appdbHave someone who has run logical replication in production review the cutover and rollback run sheet before the rehearsal. Reverse replication is easy to get subtly wrong, and a second pair of eyes is cheap insurance.
Know the sharp edges before you rely on it.
- No schema changes. DDL isn't replicated in either direction. Freeze migrations and deploys that alter tables from the cutover until AWS is gone, or the subscription will stall on the first mismatch.
- One writer at a time. RDS must receive nothing except the subscription. Stop the AWS containers and set
default_transaction_read_onlyfor the application role on RDS, so a stray cron job can't write there. Never run both directions at once; you'll get conflicts and a dead subscription. - Sequences go backwards too. A rollback needs the same sequence script run in the opposite direction, from the new primary into RDS.
- WAL retention. If RDS stops consuming, the replication slot on the new primary holds WAL and can fill the disk. Alert on slot lag and disk, and drop the slot as soon as the rollback window closes.
- Drop the forward subscription first. Remove the RDS to new-server subscription before creating the reverse one, so there is never a loop.
- Keep the network path open. Reverse replication and rollback both depend on the tunnel (or the allow-listed path) and, where it applies, the NAT gateway route. Don't remove them until the rollback window closes.
Decommissioning AWS cleanly
After the parallel period, take a final RDS snapshot and export it somewhere cheap. Then work down the list: ECS services and task definitions, ALBs and target groups, NAT gateways and their Elastic IPs (these keep billing if they're orphaned), ElastiCache, RDS, legacy VMs and static IPs, ECR repositories, unused CloudWatch log groups and S3 buckets. Remove the replication tunnel and the temporary firewall and security group rules last, once the rollback window has closed. Use Terraform for what it manages, but check Cost Explorer a week later for anything that kept charging. A forgotten NAT gateway can quietly undo a chunk of your savings.
Expected cost after the move
Treat these as planning assumptions, not quotes or benchmarks. Provider prices move, and you should get current quotes during assessment.
| Item | Before (AWS) | After (self-hosted) |
|---|---|---|
| Database | About three quarters of the total bill, one single-AZ instance | Two dedicated servers: primary plus standby, roughly 150 to 300 USD per month combined |
| Application compute | Fargate tasks plus two ALBs and two NAT gateways | One VPS or small dedicated host, roughly 20 to 60 USD per month |
| Cache | ElastiCache Serverless | Redis container, included in the app host |
| Backups | RDS automated backups and snapshots | S3-compatible storage, roughly 10 to 25 USD per month for a few hundred GB |
| DNS, email, residual AWS | Route elsewhere, email-sending service stays where it is | A few USD per month |
| Parallel run | Not applicable | AWS keeps billing for two to four weeks while it serves as the rollback target |
| Operations time | Mostly provider-managed | Real hours per month for patching, restore reviews and on-call |
Count the last row honestly. If operating the platform costs a senior engineer a day a month and an occasional bad night, that belongs in the comparison. If the example bill drops from several thousand dollars a month to a few hundred, self-hosting can still win by a wide margin, but it isn't free.
Cost savings, ROI and business impact
Every stakeholder asks the same three questions: how much will we save, when does it pay back, and what does it do to the business. The figures below are a worked example with assumed inputs, not results from a specific client. Replace them with your own numbers from Cost Explorer and your engineering rate card.
Worked example: the assumptions
| Input | Assumed value |
|---|---|
| Current AWS bill | 5,000 USD per month, about three quarters of it the database |
| Right-sizing outcome (option A) | 40 percent reduction, about one week of work (4,000 USD) |
| Self-hosted infrastructure after the move (option B) | 600 USD per month: database servers 350, app host 70, backup storage 30, DNS, email sending, registry and monitoring extras 150 |
| Extra operations time under option B | 2 engineer-days per month at a loaded rate of 600 USD per day, 1,200 USD per month |
| One-time migration cost | 30,000 USD project fee or internal effort, about 3,500 USD of AWS billing during the three-week parallel run, about 500 USD for staging, egress and tooling, 34,000 USD in total |
Worked example: the results
| Measure | A: Right-size AWS | B: Self-host | B over A |
|---|---|---|---|
| Gross monthly saving | 2,000 USD | 4,400 USD (88 percent) | 2,400 USD |
| Extra operations cost per month | 0 | 1,200 USD | 1,200 USD |
| Net monthly saving | 2,000 USD | 3,200 USD (64 percent of the bill) | 1,200 USD |
| One-time cost | 4,000 USD | 34,000 USD | 30,000 USD |
| Payback period | 2 months | about 11 months | about 25 months |
| Net benefit over 3 years | 68,000 USD | 81,200 USD | 13,200 USD |
Read the last column carefully. At a 5,000 USD monthly bill, both options beat doing nothing by a wide margin, but self-hosting beats a right-sized AWS by a modest amount over three years and carries more risk. That's a legitimate "stay and right-size" outcome, and it's why the assessment has to come before the build.
The case for migrating strengthens quickly as the bill grows or when right-sizing can't shrink the database much (because the instance is genuinely busy). Take the same pattern at a 15,000 USD monthly bill, with 1,500 USD of self-hosted infrastructure, the same operations cost, and a 51,000 USD one-time cost that includes a larger parallel run: the net saving is about 12,300 USD per month and payback comes in around four months.
The formula to use with your own numbers
net monthly saving = current monthly bill
- new infrastructure per month
- extra operations cost per month
one-time cost = project effort
+ parallel-run AWS billing
+ staging, egress and tooling
payback (months) = one-time cost / net monthly saving
3-year net benefit = (net monthly saving x 36) - one-time cost
3-year ROI = 3-year net benefit / one-time costRun it for both right-sizing and migration, then look at the incremental difference between them. That comparison is the real decision.
Impact on the business
- Freed budget. In the example, about 38,000 USD a year of net savings. That's real runway, or a meaningful fraction of an engineering hire, redirected from infrastructure to product.
- More headroom for the same money. Dedicated hardware gives far more RAM and NVMe per dollar than managed instances. For a search-heavy product, that can mean faster queries and room to grow the index without a price jump, though you should verify it with your own benchmarks rather than assume it.
- Predictable billing. Fixed monthly server costs replace usage-based charges (NAT processing, data transfer, IOPS), which makes forecasting simpler.
- Less vendor lock-in. Standard PostgreSQL, Docker and S3-compatible storage can move again later without a rewrite.
- Different risk profile. Multi-AZ automatic failover is replaced by a rehearsed manual one, so recovery time moves from roughly a minute or two to a few minutes up to tens of minutes. Price that honestly: expected extra outage hours per year multiplied by revenue (or support and churn cost) per hour of downtime should be a line in the business case.
- Team focus. Operations hours come out of the roadmap. If the team is small, this is the biggest non-financial cost.
Metrics to track after the move
Agree these before cutover, so the savings are measured and not assumed:
- Monthly infrastructure cost, and cost per active customer or per 1,000 sessions
- p50 and p95 latency for the top search queries and API endpoints versus the pre-migration baseline
- Availability and unplanned downtime minutes
- Measured restore time and backup success rate
- Engineer-hours spent on infrastructure each month
Risks, hidden costs and performance pitfalls
The savings are real, but so are the costs that don't show up on a provider's price page. Put these in front of whoever signs off on the move.
Hidden costs and operational overhead
| Cost or risk | What it looks like | How to keep it manageable |
|---|---|---|
| The pager tax | You trade a bill for an operational burden. OS patching, disk failures, certificate renewals, failed backups and the occasional 2 a.m. database outage are now yours, and on a small team that comes straight out of developer time | Automate patching and renewals, alert only on things with a runbook, agree an on-call rotation (or a support partner) before cutover, and count the hours in the cost comparison |
| Network egress | Data leaving AWS is billed: the initial copy, continuous WAL while the subscription runs, and later any export of snapshots. The reverse replication path sends data from the new provider to AWS, which AWS doesn't charge for inbound, but your new provider may meter outbound traffic | Estimate transfer volume from your WAL generation rate, check the new provider's traffic allowance, and shorten the parallel period if the numbers climb |
| Features you no longer get for free | Multi-AZ failover, one-click point-in-time restore, Performance Insights, managed minor-version upgrades | Replace each one deliberately: a standby plus a failover runbook, pgBackRest, pg_stat_statements with Grafana, a scheduled patch window |
| Hardware and provider risk | A dead disk, a failed power supply or a provider incident takes out a host, and replacement can take hours rather than minutes | RAID 1, a standby in a separate data center, off-site backups at a second provider, and a replacement SLA you've read |
| Major version upgrades | RDS gave you a console workflow. Now you plan and test the upgrade path yourself | Use the same logical replication technique to move to the next major version into a fresh server, with the same rehearsal discipline |
| Bus factor | One person who understands the setup is a risk of its own | Runbooks, a second person who has done a restore and a failover drill, and a handover review |
| Compliance and audit | Customers or auditors may ask for evidence a managed service used to supply (encryption, access logs, patch records) | Collect that evidence from day one: patch logs, access logs, backup and restore-test reports |
| Migration effort | Four to six weeks of focused engineering plus staging infrastructure and double running | Treat it as a project with a budget, not a weekend task |
Will self-hosting cause performance problems?
It can, mostly in the first hours and days, and almost always for reasons you can see coming. In steady state, a dedicated server with local NVMe often beats a network-attached cloud volume on latency, since RDS storage sits behind the network and may be throttled by provisioned IOPS or burst credits. But "often" isn't a promise. Measure it on your own queries before cutover and again after.
| Source of slowdown | Why it happens | Fix or mitigation |
|---|---|---|
| Cold cache after cutover | The new server's shared buffers and OS page cache start empty, so the first heavy searches hit disk | Warm up with pg_prewarm on the hot tables and indexes, and run the top queries before switching traffic |
| Missing planner statistics | Logical replication copies data, not statistics, so the planner chooses bad plans until stats exist | Run ANALYZE after the initial copy and again before cutover |
| Configuration differences | RDS parameter defaults differ from a stock PostgreSQL install (memory, cost parameters, autovacuum, work_mem) | Diff the RDS parameter group against your postgresql.conf line by line, then benchmark |
| Added network distance | If the app and the database end up in different data centers or providers, every query pays the round trip, and chatty ORMs multiply it | Keep the app and primary in the same data center or on the same private network, and measure per-request query counts |
| Replication overhead on the source | Logical decoding adds CPU and I/O on RDS, and a long initial copy holds a snapshot that delays vacuum there | Run the initial sync in a quiet period and watch RDS CPU, free storage and slot lag |
| Synchronous replication | Making the standby synchronous adds its network and disk latency to every commit | Use asynchronous streaming replication unless you've decided you can't lose a transaction, and then test the commit latency |
| Backups competing with queries | Full backups and WAL archiving use disk and CPU on whichever node runs them | Back up from the standby, schedule fulls off-peak, and cap pgBackRest process-max |
| Shared or noisy hardware | On a VPS, neighbors can steal CPU and I/O, and there are no burst credits to hide it | Choose dedicated servers or dedicated-CPU plans for the database, and alert on CPU steal and I/O wait |
| Memory pressure | Oversized max_connections or work_mem leads to swapping, and a swapping database is a slow database | Pool with PgBouncer, size work_mem against peak concurrent queries, and alert on swap use |
| Bloat and vacuum lag | Heavy ETL writes can outrun default autovacuum settings on the biggest tables | Tune per-table autovacuum settings and track bloat and dead tuples |
The practical rule: set a baseline from RDS before you touch anything (p50 and p95 latency for the top queries, API response times, CPU and I/O), agree an acceptable regression threshold, and compare after cutover. If the numbers fall outside it, you have the rollback path to use.
Do you have a written baseline of query latency and API response times from the current RDS setup? Without one, "it feels slower" and "it feels faster" are the only measurements you'll have after cutover.
Data protection considerations
If the database holds personal data about people in the EU or UK (contact details in a CRM, for instance), moving it between providers is a processing change you should document. You'll need a data processing agreement with the new hosting provider, a record of where backups live, encryption at rest and in transit, and an access log for who can reach the servers. See the regulation text itself: GDPR (Regulation 2016/679). Public reference data is a different matter from customer contact records, so classify tables before you decide what needs the strictest controls. This is a planning prompt, not legal advice: ask your counsel or data protection lead to confirm what applies to your case.
The full migration checklist
Print this, paste it into your tracker, and don't tick anything you haven't verified yourself.
1. Assessment
- 90 days of cost data broken down by service
- CPU, IOPS, connections and cache hit ratio from CloudWatch and Performance Insights
- Right-sizing option priced honestly against self-hosting
- Extension, parameter and cron/job inventory complete
- Tables without primary keys identified
- Business case with payback period, 3-year net benefit and success metrics agreed
- Written plan reviewed and approved before spend
2. Server build and security
- SSH keys only, root login and passwords disabled
- Firewall default deny, Docker ports verified with an external scan
- fail2ban active, unattended security upgrades configured
- PostgreSQL bound to the private interface only, scram-sha-256 authentication, TLS on
- Secrets stored encrypted, not in plain environment files in git
- Out-of-band console access tested
3. Backups and recovery
- pgBackRest stanza created, WAL archiving confirmed with
pgbackrest check - Off-site repository at a different provider, encrypted, with object lock or versioning
- Full manual restore drill done by a second person using the runbook
- Automated weekly restore test running and alerting
- Measured recovery time written down
- Standby replicating, and a manual failover runbook documented and timed in a drill
4. Monitoring
- Host, container and PostgreSQL metrics collected
- Alerts for disk, backups, replication, connections, certificates and app errors
- External uptime check and a dead-man's-switch alert
- Every alert linked to a runbook
5. Staging on real data
- Logical replication initial copy completed and verified
ANALYZErun on the new database- Top slow queries replayed and compared with production
- Latency and throughput baseline captured from RDS, with an agreed regression threshold
- Parameter group diffed against the new
postgresql.conf - ETL job run end to end against the new server
- RDS free storage monitored while the replication slot exists
6. Cutover readiness
- DNS TTLs lowered at least a week ahead, zone records copied exactly
- Dry run timed, run sheet written from it
- Replication network path (tunnel or allow-listed TLS) tested from both sides
- Sequence sync script tested in both directions
- Skipped sequences (null
last_value) reviewed by hand, and each sequence compared with the highest id in its table - Schema changes frozen from cutover until decommission
- Reverse replication to RDS tested, with
copy_data = falseand slot lag alerting - Rollback trigger and decision owner defined in writing
- Stakeholders and customers notified of the window
7. After cutover
- Verification queries passed (counts, hashes, timestamps, sequences)
- First backup and WAL archive after cutover confirmed good
- Error rate and latency compared with the pre-migration baseline
- Hot tables and indexes pre-warmed, cache hit ratio and I/O wait watched for the first days
- Egress and parallel-run costs tracked against the budget
- AWS kept idle as rollback target for the agreed period, with the tunnel and NAT route left in place
- Final RDS snapshot exported, then NAT gateways, ALBs, RDS, ElastiCache, the legacy VM and ECR removed
- Cost Explorer checked a week after teardown
8. Handover
- Architecture diagram, runbooks for restore, failover, upgrade and certificate renewal
- Credentials and keys transferred and rotated
- Team has done a restore and a failover drill themselves
- On-call and escalation path agreed
Best practices worth repeating
Keep the PostgreSQL major version the same during the move, then upgrade later as its own project. Changing version, host, provider and architecture in one weekend is how small problems become unfindable ones.
Change one variable at a time. Keep the application code frozen during the migration window. Write everything down as you go, because the person who does the cutover is often tired, and tired people follow checklists better than they follow memory.
And plan for the first minor-version upgrade, the first disk failure and the first expired credential. Those arrive eventually, and the team that has drilled them treats them as maintenance.
FAQ
How much downtime does an RDS to self-hosted PostgreSQL migration need?
With native logical replication and a rehearsed run sheet, write downtime is typically minutes rather than the hours a dump and restore needs. The exact figure depends on lag drain, sequence sync and verification, so time it in a rehearsal on your own data. Dump and restore is simpler but takes hours because of the dump, transfer, restore and index rebuilds.
Is self-hosting PostgreSQL really cheaper than RDS?
For a steady workload that needs a large instance, usually yes, by a wide margin on infrastructure cost. The trade is operational work: patching, backups, failover and on-call become yours, so include that time in the comparison.
Can I replicate from RDS to my own server with streaming replication?
No. RDS doesn't expose physical replication to external servers, so you need logical replication, a dump and restore, or a tool like AWS DMS.
What is the biggest risk of leaving a managed database?
Unverified backups. Managed services hide backup and recovery from you, and on your own hardware an untested pgBackRest setup can fail silently until the day you need it. Automated restore tests and backup-age alerts are the control.
How do I roll back if the new server has problems?
Set up reverse logical replication from the new primary back to RDS at cutover. If you need to roll back, freeze writes, confirm RDS has caught up, switch DNS back and restart the AWS containers without losing data.
Will self-hosting PostgreSQL make my application slower?
Not necessarily. Local NVMe storage often has lower latency than network-attached cloud volumes. The risks are a cold cache after cutover, missing planner statistics, differing configuration and extra network distance, and you can control all of them by running ANALYZE, pre-warming, benchmarking against a baseline and keeping the app and database close together.
How long does it take to pay back a move from RDS to self-hosted PostgreSQL?
It depends on the size of your bill and on how much right-sizing AWS could save instead. In an illustrative example with a 5,000 USD monthly bill, payback is about 11 months after counting operations time and one-time costs, and it shortens quickly as the bill grows. Compare against a right-sized AWS baseline, not against the current bill.
How do the new server and RDS connect to each other during the migration?
Replication needs a route in both directions. The preferred setup is a site-to-site VPN or a WireGuard tunnel so PostgreSQL stays on private addresses. The fallback is a time-boxed path allow-listed by security group, host firewall and pg_hba.conf, with TLS. Test it from both sides before starting replication, and keep it until the rollback window closes.
Should I right-size AWS instead of migrating?
Sometimes, yes. If utilization data shows a smaller RDS class, a reservation and fewer NAT gateways close most of the cost gap, staying is lower risk. Price both options with real metrics before deciding.
Weighing a database move?
Stonetusker starts with the assessment: your cost data, your utilization metrics and your queries, with a straight answer on whether to migrate or right-size. If migrating wins, we can plan the cutover, build the backups and monitoring, test the restores and hand it over so your team can run it.
Talk to us about your migrationRelated reading: Infrastructure as Code Done Wrong: 7 IaC Anti-Patterns Creating Cloud Chaos · Observability on a Startup Budget: What to Instrument First · Migrating from Jenkins to GitHub Actions Without Stopping Releases
About the author
Subeesh Sivanandan is the Founder & CEO of Stonetusker Systems, with 26+ years across platform engineering, DevOps, CI/CD, release engineering and embedded Linux. Connect on LinkedIn.



