Migrated an App and PostgreSQL Database from Azure to AWS—And the “Successful” Database Migration Still Needed Fixing
An end-to-end migration lab: private networking, Site-to-Site VPN, AWS DMS full load + CDC, database cutover, and a private EC2 application behind an HTTPS load balancer.
Migration is easy in architecture diagrams. Draw an arrow from Azure to AWS, add a cloud icon, and everyone goes home happy. Real migrations involve routes, ports, replication state, application secrets, database defaults, and at least one moment when an ALB says 504 Gateway Timeout instead of saying hello.
This was a hands-on lab, not a claim about a production enterprise cutover. I built an Azure environment to simulate an on-premises source and migrated a Node.js application and PostgreSQL database to AWS. The goal was to practice the decisions, checks, and failure modes that matter in a real migration.
What I built
Source (Azure, simulating on-prem): an Ubuntu VM with a private address, a Node.js/Express order-management app on port 3000, and PostgreSQL 16 containing customers, products, and orders.
Target (AWS): a VPC (10.16.0.0/16), private Amazon RDS for PostgreSQL, AWS DMS, and a private EC2 instance running the application. An internet-facing Application Load Balancer (ALB) terminates HTTPS and forwards traffic to the private app on port 3000.
Migration connectivity: a route-based IPsec Site-to-Site VPN between Azure (172.16.0.0/16) and AWS (10.16.0.0/16). The Azure VPN connection showed Connected; on AWS, one VPN tunnel was UP and the other DOWN. That was sufficient for the lab, but not a highly available production VPN design.
Migration path (private traffic)
Azure simulated on-prem AWS
172.16.0.0/16 10.16.0.0/16
+----------------------+ IPsec VPN +----------------------+
| Ubuntu VM 172.16.0.4|===============>| DMS replication |
| Node.js :3000 | | RDS PostgreSQL |
| PostgreSQL 16 | | Private subnets |
+----------------------+ +----------------------+
Final application request path
Browser --HTTPS :443--> Public ALB --HTTP :3000--> Private EC2
|
+--TCP :5432--> Private RDS
Security note: This diagram shows the logical data flow, not every route table, gateway, or security-group rule. The public ALB is the entry point; the EC2 application and RDS database do not need public IPs.
Phase 1 — Establish the source environment
I created the Azure virtual network, private workload subnet, VPN gateway subnet, and an Ubuntu VM without a public NIC address. The source VM hosted the application and PostgreSQL database. A private VM is a good simulation of an on-prem server: reaching it requires an intentional management or network path, rather than a casual SSH connection from the internet.
Figure 1: The Azure VM used as the simulated on-premises source.

Figure 2: Azure network segmentation for the workload and VPN gateway.
The source database contained three tables: customers, products, and orders. The application exposed a health endpoint and supported reading and writing data, giving me something meaningful to validate after migration.
Phase 2 — Connect Azure to AWS over IPsec
The Azure VPN gateway and AWS virtual private gateway were configured with compatible tunnel parameters. I created an Azure Local Network Gateway representing the AWS VPN endpoint and remote address space. On the AWS side, the private route tables needed a route back to 172.16.0.0/16 through the virtual private gateway.

Figure 3: Azure VPN gateway used for the lab connection.

Figure 4: The Azure-to-AWS VPN connection in the Connected state.
The important lesson: a VPN connection showing Connected is not proof that your application traffic can flow. Both directions need working routes; firewalls, network security groups, and AWS security groups must permit the actual source/destination traffic. For database replication, that meant PostgreSQL TCP port 5432, not the application port 3000.
I also treated the single operational tunnel as a lab limitation. A production design should validate redundant tunnels, failover behavior, monitoring, and appropriate routing resilience.
Phase 3 — Prepare AWS RDS and DMS
I provisioned private Amazon RDS for PostgreSQL and set up AWS Database Migration Service (DMS) endpoints for the Azure source database and the AWS target. The source was PostgreSQL 16 and the RDS target reported PostgreSQL 18.3, so this exercise also required attention to engine-version differences and schema compatibility.

Figure 5: AWS DMS source endpoint for the Azure-hosted PostgreSQL database.

Figure 6: AWS DMS target endpoint for Amazon RDS PostgreSQL.
I used full load plus change data capture (CDC). Full load copies the existing rows; CDC then applies ongoing changes while the source is still in use. This is valuable because a migration is rarely just “copy a database and hope nobody edits anything.”
The migration plan was:
- Establish and test private connectivity and database access.
- Create and test DMS source and target endpoints.
- Run the pre-migration assessment and review warnings.
- Run full load and CDC, monitoring task and table status.
- Compare data and validate application read/write behavior.
- Freeze source writes, confirm target consistency, and stop replication.
- Reconfigure and move the application to AWS, then verify end-to-end access.
Phase 4 — Assess, replicate, validate

Figure 7: The DMS task configured for the migration.

Figure 8: Pre-migration assessment results.
The pre-migration assessment reported 43 checks: 26 passed, 2 warnings, 15 skipped, and 0 failed. “Skipped” and “warning” do not mean “ignore”; they tell you where manual review is still necessary.

Figure 9: DMS table validation showed three validated tables, with no reported mismatches or validation errors.

Figure 10: Comparing source and target row counts during migration.
At one comparison point, the source and target both showed 4 customers, 3 products, and 3 orders. These were point-in-time counts—not a substitute for comprehensive data validation. DMS validation subsequently reported 3 validated tables, 0 mismatched rows, and 0 validation errors.
The gotcha: rows migrated, but the schema was not application-ready
The most useful lesson in this lab was that a healthy-looking DMS task is not the same as a fully functional database cutover.
After replication, I had to repair PostgreSQL schema behavior on the target: sequence-backed IDs, defaults, and missing unique/foreign-key constraints. I also had to grant the application user the required sequence permissions. For example, in a controlled lab where that level of access is appropriate:
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO appuser;
The precise schema changes depend on the source DDL and migration configuration; the command above is not a universal production permission recipe. In a real migration, I would migrate schema objects explicitly, compare source and target DDL, validate constraints and sequences, and test inserts and updates before approving cutover.
Why it matters: a row-count dashboard can be green while the first real customer insert fails. Databases do not give bonus points for beautiful dashboards.
Phase 5 — Cut over the database
Once I had validated the data, I stopped writes to the source, completed the migration checks, and stopped the DMS replication task. I then tested target-side writes. Additional customer records were inserted on the AWS side after cutover, so later target counts were intentionally different from the earlier source comparison.

Figure 11: Replication stopped after the database cutover.
The distinction is important: a post-cutover target row count should not be compared blindly against an earlier source snapshot. You need a defined validation window, a write-freeze boundary, and an understanding of which system became the source of truth.
Phase 6 — Move the Node.js app to private EC2
I copied the application to a private AWS EC2 instance over the established private network path, installed its Node.js dependencies, and configured it to connect to the RDS endpoint. The transfer excluded .env and node_modules, allowing dependencies and configuration to be created appropriately on the target. The application ran as a systemd service, and its /health endpoint exercised a database connectivity query.
The target app's connection path was:
Private EC2 app (:3000) -> RDS security group (:5432) -> PostgreSQL
I used SSL for the database connection, but the lab configuration disabled server-certificate verification (rejectUnauthorized: false). That is not suitable for production. A production migration should use RDS CA certificates and verify the server identity, with credentials in a managed secret store rather than hard-coded or committed .env files.
Phase 7 — Put the private application behind HTTPS
The ALB was internet-facing; the application instance remained private. I configured an HTTP listener on 80 to redirect to 443, an HTTPS listener on 443 using an ACM certificate, and a target group forwarding to the app on HTTP 3000. The health check used /health.
User enters https://app.example.com
|
v
ALB listener :443 (TLS termination)
|
v
Target group :3000 (health check /health)
|
v
Private EC2 security group allows :3000 FROM ALB security group
|
v
Node.js service :3000 -> RDS :5432
The 504 Gateway Timeout I hit was a practical reminder that the ALB listener port and application port are separate. The listener accepted HTTPS on 443, but the target instance's security group initially did not allow the ALB to reach the application on port 3000. Once I allowed TCP 3000 from the ALB security group, the target could respond.
One more detail worth getting right: a wildcard ACM certificate only matches appropriate domain names. Testing an ALB-generated hostname does not prove a custom DNS name or public DNS cutover was configured. I verified the application path through the ALB, but I am not claiming a completed custom-domain DNS cutover here.

Figure 12: The order-management application used for end-to-end testing. The page's original environment label was hard-coded and is not, by itself, proof of which server handled the request.
What I would check before calling a real production migration complete
- Network: VPN tunnel redundancy, both-direction routes, SG/NSG policy, DNS resolution, and private-only database access.
- Database: DDL parity, indexes, constraints, sequences, roles, permissions, row counts, validation errors, application transactions, and a tested recovery path.
- Cutover: approved freeze window, final CDC lag checks, explicit source-of-truth decision, rollback criteria, and stakeholder sign-off.
- Application: process supervision, health checks, logs, connection pool settings, TLS validation, secrets management, and ALB target health.
- Operations: monitoring, backup/restore exercises, runbooks, cost controls, and post-cutover support.
Future modernization (not implemented in this lab)
After a successful rehost, the next stage could be containerizing the app, pushing images to Amazon ECR, deploying to ECS/Fargate or EKS, managing infrastructure with Terraform, using AWS Secrets Manager, adding CI/CD, and strengthening observability. I'd also revisit RDS high availability and VPN redundancy.
Those are next steps, not things I am claiming to have delivered in this migration.
Final thoughts
The biggest takeaway wasn't a service name. It was learning that connectivity, replication, schema fidelity, application behavior, and cutover are five different things—and every one must be validated independently.
A green DMS status is good news. A working application that can read and write safely after cutover is better news.
It's been a long read, thank you for staying through to the end... Cheers!


Top comments (0)