DEV Community

Dimeji Ojewunmi
Dimeji Ojewunmi

Posted on

Migration & Modernization From Azure to AWS

My Hands-On Migration of a Node.js App and PostgreSQL Database from Azure to AWS

My migration lab, from a blocked local network to PostgreSQL on RDS and a private application on EC2.

Full load: 100%. Three tables validated. Zero mismatched records.

Those were the results on my AWS DMS dashboard. The data had made it from Azure to Amazon RDS, and the row counts agreed.

There was still work hiding behind that success: sequence-backed IDs, defaults, constraints, and permissions needed attention before the target database was ready for the application.

I built this lab to practice moving a Node.js order-management app and its PostgreSQL database to AWS. I used an Azure VM as the simulated on-premises source. There were only three tables, which made it easy to compare the data while I worked through the networking, database cutover, and application move.

The database copy was one step. Getting the application to use it—and then making the application reachable through the load balancer—took more work.

I originally wanted to run this from home

My original plan was to run the source locally and connect it to AWS. I ran into connectivity blockers with my MTN and Airtel 5G router setups before I could get to the migration itself.

I still wanted to carry out the project, so I moved the source environment to an Azure VM. There, I could configure the private network and VPN connection myself.

I kept the application and PostgreSQL on a private machine, with AWS on a separate network and a VPN between them. That let me practice the migration steps I had set out to learn.

The MTN and Airtel 5G router limitations remained unresolved. Using Azure let me continue, and I ended up working through the network configuration in both clouds.

My lab setup

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
Enter fullscreen mode Exit fullscreen mode

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 — Setting up the source in Azure

The source began with an Azure virtual network, a private workload subnet, a VPN gateway subnet, and an Ubuntu VM hosting both the application and PostgreSQL. I gave the VM no public NIC address. That decision made connectivity part of the exercise from the beginning: every connection to the source needed a deliberate path.

Azure Ubuntu VM showing private IP 172.16.0.4 and no public NIC IP

Figure 1: The Azure Ubuntu VM used as the simulated on-premises source. Its private IP is 172.16.0.4; the public IP shown lower on the page belongs to the NAT gateway.

Azure VNet subnets: private-subnet, GatewaySubnet, and AzureBastionSubnet

Figure 2: The Azure VNet contains separate workload, VPN gateway, and Bastion subnets. The workload subnet is associated with private-SG.

The database contained customers, products, and orders. The application could read and write data and exposed a health endpoint. That gave the migration a practical acceptance test: after the move, could the application still use the database the way it did before?

Copying three tables would be easy to observe. Preserving their behavior would take more care.

Phase 2 — “Connected” was the first checkpoint

Before DMS could move a row, the two private networks needed a working conversation.

I configured compatible IPsec tunnel parameters on the Azure VPN gateway and AWS virtual private gateway. In Azure, a Local Network Gateway represented the AWS VPN endpoint and remote address space. In AWS, the private route tables needed a route back to 172.16.0.0/16 through the virtual private gateway.

Azure showed Connected. The next step was to check what the AWS side could actually tell me.

Azure route-based VPN gateway onprem-vpn-gateway

Figure 3: The Azure VPN gateway uses the VpnGw1AZ SKU and a route-based VPN. Its public IP is the Azure-side VPN endpoint.

Azure Local Network Gateway representing the AWS VPN endpoint

Figure 4: The Azure Local Network Gateway represents the AWS tunnel endpoint and the remote AWS address space, 10.16.0.0/16.

Azure VPN connection azure-to-aws-tunnel1 showing Connected

Figure 5: The Azure gateway connection list shows azure-to-aws-tunnel1 in the Connected state.

Follow the connection into AWS

On AWS, I created azure-onprem-cgw to represent the Azure VPN gateway's public IP, 4.255.22.3. I attached azure-migration-vgw to Demo-VPC, then linked the gateways through the azure-to-aws-vpn Site-to-Site VPN connection.

AWS customer gateway representing the Azure VPN gateway

Figure 6: The AWS customer gateway is Available and points to the Azure VPN gateway public IP. Although this page lists a BGP ASN, the VPN connection in this lab uses static routing.

AWS virtual private gateway attached to Demo-VPC

Figure 7: The AWS virtual private gateway is Available and its VPC attachment state is Attached.

The AWS tunnel view gave me the other half of the connectivity check: Tunnel 1 was Up, with outside IP 3.227.45.240, matching the endpoint configured in Azure's Local Network Gateway. Tunnel 2 was Down, so this lab had only one operational tunnel.

AWS VPN Tunnel 1 Up and Tunnel 2 Down

Figure 8: AWS reports one operational tunnel and explicitly warns that both tunnels are not in use.

The tunnel status was only one piece of the path. I checked the remote network in two places: the VPN connection's static routes and the VPC subnet's route table. The VPN static route listed the Azure address space, 172.16.0.0/16. The priv-RT route table, associated with priv-subnet, directed that same address space to the virtual private gateway.

AWS VPN static route for the Azure network

Figure 9: The Site-to-Site VPN uses static routing and lists 172.16.0.0/16 as an Available remote prefix.

AWS private subnet route table with an active route to Azure

Figure 10: The Azure route, 172.16.0.0/16, is Active and targets the virtual private gateway. A separate route to 10.200.0.0/24 is Blackhole; it does not establish connectivity for that prefix and is distinct from the Azure route.

Give PostgreSQL a path through the firewall

The Azure network security group allowed TCP 5432 from the AWS VPC range, 10.16.0.0/16, to the source VM, 172.16.0.4. This captured the source-side database rule used for replication.

Azure NSG rule allowing PostgreSQL traffic from the AWS VPC

Figure 11: The Azure inbound rule allows PostgreSQL traffic from 10.16.0.0/16 to 172.16.0.4 on TCP port 5432.

At this point, Connected had become a useful starting point for investigation. I still needed routes in both directions and firewall rules that permitted the actual traffic. Replication needed PostgreSQL on TCP 5432. The application would later need its own path on 3000—a distinction that would matter again when I reached the load balancer.

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 — Connecting DMS to the source and target

With the private network path established, I provisioned private Amazon RDS for PostgreSQL and set up AWS Database Migration Service (DMS) endpoints for the Azure source and 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.

AWS DMS source endpoint for PostgreSQL at 172.16.0.4

Figure 12: The DMS source endpoint connects to migrationdb on the Azure VM using dms_user and port 5432. The screenshot shows SSL mode None.

AWS DMS target endpoint for Amazon RDS PostgreSQL

Figure 13: The DMS target endpoint connects to migrationdb on Amazon RDS using port 5432, with SSL mode Require.

Verify connectivity from the DMS replication instance

The DMS replication instance, azure-postgres-migration, had private IP 10.16.2.71 in the AWS VPC and no public IP. I tested each database endpoint from that instance to verify the connection used by the migration service.

AWS DMS replication instance with private IP and no public IP

Figure 14: The replication instance is Available, uses private IP 10.16.2.71, and lists its VPC, subnet group, and security group.

Successful DMS connection test to the Azure PostgreSQL source

Figure 15: The source endpoint connection test from azure-postgres-migration is Successful, providing a database connectivity check across the private migration path.

Successful DMS connection test to the RDS PostgreSQL target

Figure 16: The target endpoint connection test from the same replication instance is Successful. These tests establish endpoint access at the time of testing; migration and data validation are checked separately.

Both endpoint tests returned Successful. That gave me evidence that the replication instance could connect to each database.

I chose full load plus change data capture (CDC): copy the existing rows, then apply changes made while the source remained in use. The initial copy would get the target populated; CDC would keep changes moving until the cutover boundary.

I organized the migration around these checkpoints:

  1. Establish and test private connectivity and database access.
  2. Create and test DMS source and target endpoints.
  3. Run the pre-migration assessment and review warnings.
  4. Run full load and CDC, monitoring task and table status.
  5. Compare data and validate application read/write behavior.
  6. Freeze source writes, confirm target consistency, and stop replication.
  7. Reconfigure and move the application to AWS, then verify end-to-end access.

Phase 4 — The dashboard reached 100%

Before relying on the task results, I checked the pre-migration assessment. Its summary had a little more nuance than a simple pass or fail.

AWS DMS assessment showing 26 passed, 2 warnings, 15 skipped, and 0 failed

Figure 17: The assessment summary reports 26 passed checks, 2 warnings, 15 skipped checks, and 0 failures.

The assessment reported 43 checks: 26 passed, 2 warnings, 15 skipped, and 0 failed. The warnings and skipped checks still called for manual review.

Then came the encouraging result: full load reached 100%, with replication ongoing. The table statistics and direct SQL comparisons gave me two more ways to inspect what had arrived.

AWS DMS full load at 100 percent with replication ongoing

Figure 18: The DMS task shows “Load completed, replication ongoing”: full load has reached 100%, and CDC is still running.

AWS DMS table statistics showing three completed and validated tables

Figure 19: DMS reports three completed tables and three validated tables, with zero mismatched records, validation errors, or pending records.

PostgreSQL source and target queries returning matching row counts

Figure 20: The source and RDS target each return 4 customers, 3 products, and 3 orders. The terminal also records an initial failed login followed by a successful TLS connection.

At one comparison point, both databases returned 4 customers, 3 products, and 3 orders. DMS validation subsequently reported 3 validated tables, 0 mismatched rows, and 0 validation errors.

Those checks established something valuable about the copied data. They still left an application question open: what would happen when the target needed to create the next record?

The work the row counts could not reveal

After replication, I had to repair sequence-backed IDs, defaults, and missing unique and foreign-key constraints on the target. The application user also needed the required sequence permissions.

These details shape everyday application behavior: how a new row gets its ID, which values appear automatically, and which relationships the database enforces. Existing rows can look correct while that behavior still needs work.

One of the permission changes in this lab was:

GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO appuser;
Enter fullscreen mode Exit fullscreen mode

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.

That changed how I read the green dashboard. It was evidence that the data checks had passed. I still needed to exercise the operations the application would depend on after cutover.

Phase 5 — The cutover happened in .env

Cutover introduced a boundary the earlier screenshots could not capture by themselves: the moment new writes belonged on AWS.

DMS had copied and validated the data, but the application still had its original database configuration. Its .env pointed to the local PostgreSQL host. Until that changed, the application would keep connecting to the source database, regardless of what the DMS dashboard showed.

After validating the data, I stopped source writes, completed the migration checks, and stopped the DMS replication task. Then came the configuration change that made the migration real for the application: I changed the PostgreSQL host in .env from the local host to the Amazon RDS endpoint.

The essential change looked like this. DB_HOST is an illustrative variable name; use the name your application's database configuration actually reads.

# Before: PostgreSQL on the application VM
DB_HOST=localhost

# After: Amazon RDS PostgreSQL
DB_HOST=database-migration.cojc20qugnja.us-east-1.rds.amazonaws.com
Enter fullscreen mode Exit fullscreen mode

These are before-and-after alternatives, not two entries to keep in the same file. The rest of the connection configuration also needed to match the target: database name, port, application credentials, and SSL settings. A running Node.js process must reload its configuration—typically through a service restart—to use the changed .env value.

At this stage, the application was still on the Azure VM. Its database connection now crossed the private VPN to RDS:

Before: Azure app -> local PostgreSQL
After:  Azure app -> Site-to-Site VPN -> Amazon RDS PostgreSQL
Enter fullscreen mode Exit fullscreen mode

That separated the database cutover from the later application move to EC2. I could test the target database with the application before changing the application's hosting environment as well.

I then tested target-side writes. Additional customer records were inserted on AWS after cutover, so the later target count intentionally differed from the earlier source comparison.

AWS DMS task showing load completed and replication stopped

Figure 21: The DMS task is stopped, with full load at 100%. This records the replication state; the write freeze and application cutover checks are separate steps.

The earlier count of four customers remained a useful migration checkpoint. It was no longer the expected count forever. Once AWS accepted new writes, comparing its current data with an old source snapshot required that context.

The application now pointed to RDS. Its compute still lived in Azure; moving that to EC2 was the next step.

Phase 6 — Moving the 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 recreated its connection configuration with the RDS endpoint selected during database cutover. The transfer excluded .env and node_modules, so dependencies and environment settings were set up on EC2 separately. The application ran as a systemd service, and its /health endpoint exercised a database connectivity query.

AWS EC2 instance in a private subnet with no public IPv4 address

Figure 22: The AWS private-server instance uses private IP 10.16.2.132 in priv-subnet, within Demo-VPC, and has no public IPv4 address. This screenshot establishes its network placement, not application readiness.

The target app's connection path was:

Private EC2 app (:3000) -> RDS security group (:5432) -> PostgreSQL
Enter fullscreen mode Exit fullscreen mode

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 — Serving the private application over HTTPS

The final request path had several distinct hops: a browser reaching the public ALB, the ALB reaching private EC2, and the application reaching RDS.

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. The ALB was internet-facing; the application instance remained private.

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
Enter fullscreen mode Exit fullscreen mode

The EC2 security group allowed TCP 3000 from the ALB security group, so the load balancer could forward requests to the application while the instance remained private.

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.

Order-management application displaying customers, products, and orders

Figure 23: The order-management application displays six customers, three products, and three orders. Its original environment label was hard-coded; this screenshot alone does not establish the serving host, database endpoint, or HTTPS/ALB path.

The shortcuts I would revisit

I got the lab working, but I would not carry every choice into a production setup. The single VPN tunnel and disabled database certificate verification are two clear examples. Before using this approach for a live workload, I would go back through these checks:

  • 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.

There is still more I can do with this app

For now, the application runs on EC2. I would like to build on that by containerizing it, storing the image in ECR, and trying ECS/Fargate or EKS. Terraform would also give me a way to recreate the infrastructure without repeating the console setup.

Those would be separate exercises. This lab stops at the application and database migration; secrets management, CI/CD, and better monitoring are still on my list, along with RDS high availability and the second VPN tunnel.

Looking back

I started this project wanting to connect my local environment to AWS. The MTN and Airtel 5G router blockers changed the setup, but I was able to continue with Azure as the source.

The part that stays with me is the database cutover. I could see the rows in RDS, but my app was still configured to use local PostgreSQL. Changing the host in .env was the step that made the application use the migrated database. Moving the application itself to EC2 came afterward.

These are the details I wanted to get hands-on experience with when I started. Having worked through them, I have more to check than a completed DMS task: the app's connection settings, its ability to read and write, and the path a request takes to reach it.

Thanks for reading and following along with my lab. Cheers!

Top comments (0)