Azure to AWS Migration Lab: A Reproducible Guide to Site-to-Site VPN, PostgreSQL DMS, and Private EC2

I migrated a Node.js application and PostgreSQL database from an Azure-hosted simulated on-premises environment to AWS. This is the build-and-test guide, including the network configuration, migration order, actual validation evidence, and the things that broke.

Scope: A completed hands-on lab, not a production migration. Azure represented the source data centre. Replace all example CIDRs, addresses, passwords, resource names, and security policies to fit your own environment. Do not copy the lab's insecure shortcuts into production.

0. Architecture and the migration plan

 SOURCE: AZURE (simulated on-prem) TARGET: AWS VNet 172.16.0.0/16 VPC 10.16.0.0/16 Ubuntu VM 172.16.0.4 AWS DMS replication resources ├─ PostgreSQL 16 :5432 ---+ ├─ source endpoint → Azure PostgreSQL └─ Express app :3000 | └─ target endpoint → RDS PostgreSQL | | Azure VPN gateway | IPsec Site-to-Site VPN | public 4.255.22.3 <========+====================> AWS VGW | Azure Local Network Gateway attached to VPC | AWS tunnel outside 3.227.45.240 v Private RDS PostgreSQL :5432 AFTER APPLICATION CUTOVER Internet → public ALB HTTPS :443 → target group HTTP :3000 → private EC2 10.16.2.80 → private RDS TCP :5432 

Why this order matters: Build private networking first; prove bidirectional routing; allow database traffic; prepare RDS; migrate data with full load and CDC; validate database objects and rows; freeze source writes and cut over; then move the app and place an ALB in front. A healthy ALB cannot rescue a broken database, and a healthy DMS task cannot rescue missing PostgreSQL defaults.

Component Lab configuration Why it exists
Azure source VNet 172.16.0.0/16 Simulated on-prem network
Azure workload VM 172.16.0.4, Ubuntu 24.04 Source Node.js and PostgreSQL
Azure workload subnet 172.16.0.0/24 Private source VM
Azure GatewaySubnet 172.16.255.0/24 Azure VPN gateway
AWS VPC 10.16.0.0/16 Migration destination
AWS private EC2 10.16.2.80 Migrated Node.js app
AWS RDS Private PostgreSQL target Managed database
AWS DMS Full load + CDC Initial rows and ongoing changes
Public ALB HTTPS 443 → app port 3000 Controlled application ingress

1. Build the Azure source

In Azure Portal → Virtual networks → Create, create a VNet such as private-vnet with address space 172.16.0.0/16. Create private-subnet (172.16.0.0/24), GatewaySubnet (172.16.255.0/24) and, if using Azure Bastion, AzureBastionSubnet (172.16.1.0/26). Use an NSG to restrict administration and application/database access. Deploy an Ubuntu 24.04 VM named on-prem-workload on the private subnet, with no public NIC IP; the lab's VM address was 172.16.0.4. Provide outbound package access through a deliberate egress solution (the lab used an Azure NAT gateway).

Azure source Ubuntu VM
Source VM: Azure is standing in for the on-premises data centre.

Azure VNet subnet layout
VNet subnet layout, including the reserved gateway subnet.

Install PostgreSQL 16 and Node.js on the VM. Create a database named migrationdb, an application role with only required privileges, and three tables: customers, products, orders. Populate sample data. Expose the Express app on port 3000 and implement /health with a database SELECT 1 check. Verify locally before involving AWS:

sudo systemctl status postgresql sudo -u postgres psql -d migrationdb -c '\dt' curl -i http://127.0.0.1:3000/health 

To permit private PostgreSQL connections from DMS, configure listen_addresses and an appropriately scoped pg_hba.conf rule, then allow TCP 5432 in the Azure NSG and VM firewall from the actual DMS source IP/CIDR, not the internet. Enable PostgreSQL logical replication and verify the settings required by your DMS version (for example wal_level=logical, adequate replication slots and WAL senders); restart PostgreSQL when a parameter requires it. Keep credentials outside source control.

2. AWS network: VPC, subnets, routes and security groups

In AWS VPC → Your VPCs, create 10.16.0.0/16. Create private subnets in at least two AZs for the RDS subnet group; create suitable subnets for DMS and a private app subnet (the final EC2 address was 10.16.2.80). Create public subnets and an internet-facing ALB only when ready for application ingress. Route public subnets to an Internet Gateway. A private subnet must not acquire a direct internet route just because its application needs a package update; use an intentional NAT or private endpoint path where necessary.

For the private subnet route table, add a route for the Azure source network:

Destination Target 10.16.0.0/16 local 172.16.0.0/16 AWS Virtual Private Gateway (VGW) 

The destination CIDR must match Azure's actual address space. Without the return route, the tunnel can show UP while database connections time out.

3. AWS Site-to-Site VPN — do not skip the AWS side

This is the AWS half of the build that was missing from my previous article.

Step A — Create the Customer Gateway. In VPC → Customer gateways → Create customer gateway, choose static routing for this lab and enter the public IP of the Azure VPN gateway (4.255.22.3 in my build). Give it a descriptive name such as azure-onprem-cgw. A Customer Gateway represents the remote VPN device; it is not an EC2 instance or the AWS gateway itself.

Step B — Create the Virtual Private Gateway. Go to VPC → Virtual private gateways → Create, name it, then attach it to the AWS VPC (10.16.0.0/16). In this lab the AWS VGW was vgw-0bbe728510ee2c71a. The VGW is AWS's VPN termination point for this design.

Step C — Create the VPN connection. Go to VPC → Site-to-Site VPN connections → Create VPN connection. Select the VGW, select the Customer Gateway, choose Static routing and enter the remote Azure CIDR 172.16.0.0/16. The lab VPN was vpn-055862c3a897d8f09. Download the AWS VPN configuration, including the tunnel outside IPs and tunnel-specific pre-shared keys. Do not publish the PSKs. AWS provides two tunnels; configure both for resilient operation where possible.

Step D — Update AWS route tables. For the subnets containing DMS and any EC2 hosts that must reach Azure, add 172.16.0.0/16 → VGW, or use appropriate route propagation where configured. Confirm that NACLs and security groups allow the intended traffic and return traffic. AWS security groups are stateful; NACLs are stateless.

Step E — Check tunnel status. In VPC → Site-to-Site VPN connections → Tunnel details, inspect both tunnels. My lab had Tunnel 1 UP and Tunnel 2 DOWN. The lab could proceed on the working tunnel, but that is not production high availability. A reader should aim for two working tunnels and test failover.

Original AWS configuration screenshots

These are the original AWS console screenshots, now hosted on DEV.to and shown in the order captured. Review names and IDs before public publication.

AWS Customer Gateway — remote Azure public IP

AWS Customer Gateway configuration

AWS Virtual Private Gateway — AWS VPN termination

AWS Virtual Private Gateway

AWS Site-to-Site VPN connection — gateway association and static routing

AWS Site-to-Site VPN connection

AWS VPN tunnel state — verify which tunnel is UP

AWS VPN tunnel details

AWS VPN tunnel listing — second view of tunnel health

AWS VPN tunnel listing

AWS VPN static route — Azure CIDR 172.16.0.0/16

AWS VPN static route

This last screenshot shows the VPN connection's static route, not the VPC subnet route table. You must also verify the applicable VPC subnet route table points Azure-bound traffic to the VGW. Those are two separate configuration checks.

4. Azure VPN gateway and the Azure half of the connection

In Azure → Virtual network gateways → Create, choose VPN, Route-based, and a compatible SKU; the lab used onprem-vpn-gateway, VpnGw1AZ, IKEv2, with public IP 4.255.22.3. The VNet must contain GatewaySubnet before deploying the gateway.

Azure VPN gateway
Azure VPN gateway and its public-facing VPN endpoint.

Create a Local Network Gateway representing AWS, not Azure: use AWS Tunnel 1's public outside IP (3.227.45.240 in my lab) and AWS network prefix 10.16.0.0/16. Next go to Virtual network gateway → Connections → Add, choose Site-to-site (IPsec), select the Local Network Gateway, and supply the PSK from the matching AWS tunnel. Ensure IKE/IPsec parameters agree on both sides. The lab connection was azure-to-aws-tunnel1.

Azure VPN connection connected
The Azure VPN connection showed Connected.

Connectivity test sequence: (1) AWS tunnel status UP; (2) Azure connection Connected; (3) AWS route to 172.16.0.0/16; (4) Azure remote network 10.16.0.0/16; (5) NSG, NACL and security group rules; (6) TCP 5432 connectivity to PostgreSQL from a permitted AWS test host or DMS endpoint test. ICMP ping is optional and may be blocked; a successful nc -vz 172.16.0.4 5432 or database connection is more meaningful for this workload.

5. Create private RDS PostgreSQL

In RDS → Databases → Create database, choose PostgreSQL, a DB subnet group using private subnets, Public access: No, and a security group allowing TCP 5432 only from the actual DMS replication path and, later, the application EC2 security group. The target in my lab reported PostgreSQL 18.3, while the Azure source ran PostgreSQL 16; that version difference makes compatibility checks essential. Create the target database migrationdb and the required database roles/permissions. Confirm that the DMS target can connect before starting replication.

Example test from a correctly networked client:

psql 'host=<RDS_PRIVATE_ENDPOINT> port=5432 dbname=migrationdb user=<DB_USER> sslmode=verify-full' 

Use the AWS RDS CA bundle and valid server-name verification in a production-grade setup. My lab application temporarily used rejectUnauthorized: false while troubleshooting TLS; that bypasses certificate validation and must not be copied into production.

6. Configure AWS DMS: source, target and migration task

In AWS DMS, create replication capacity in subnets with routes to both the Azure source and AWS RDS target. Ensure its security group permits outbound TCP 5432 and both database-side controls allow the connection. Depending on DMS mode/version, use a replication instance or serverless replication configuration.

Source endpoint: engine PostgreSQL; server 172.16.0.4; port 5432; database migrationdb; dedicated DMS credentials with required read/replication permissions. Test endpoint connectivity.

DMS source endpoint
Source endpoint points to the PostgreSQL database in Azure over the VPN.

Target endpoint: engine PostgreSQL; server = your private RDS endpoint; port 5432; database migrationdb; migration role with required target permissions. Test endpoint connectivity.

DMS target endpoint
Target endpoint points to private RDS, not a publicly exposed database.

Create a migration task with Migrate existing data and replicate ongoing changes (full load + CDC). Include the public schema and relevant tables through table mappings. Configure validation and logging; select appropriate preparation settings only after deciding whether the target schema is pre-created. Starting a destructive target-preparation mode against the wrong database is a real migration risk.

DMS migration task
Migration task summary.

Run the pre-migration assessment. Mine showed 43 checks: 26 passed, 2 warnings, 15 skipped, 0 failed. Review the warnings and skipped checks rather than treating a zero-failure count as universal approval.

DMS assessment
Assessment results from the actual lab.

Monitor table statistics, task status, logs, CDC latency, and target-side SQL queries. Do not cut over based only on a progress bar.

7. Validate rows and database behaviour

The DMS validation reported 3 validated tables, 0 mismatches and 0 validation errors.

DMS validation
All three tables passed DMS validation in this lab.

Compare source and target counts before the final write freeze:

SELECT 'customers' AS table_name, COUNT(*) FROM public.customers UNION ALL SELECT 'products', COUNT(*) FROM public.products UNION ALL SELECT 'orders', COUNT(*) FROM public.orders; 

At one comparison point, the source and target both had 4 customers, 3 products and 3 orders. The counts later changed through deliberate target-side testing; use the comparison at a clearly defined checkpoint, not as a timeless claim.

Source and target row comparison
Row comparison during migration validation.

The surprising failure: DMS copied and validated the table rows, but some PostgreSQL schema behaviour still needed repair. On the target I checked and corrected missing SERIAL/identity sequence defaults, timestamp defaults, uniqueness/foreign-key constraints, and permissions. For example, when a sequence exists but the app role cannot use it:

GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO appuser; 

Do not blindly run that grant in production: restrict it to the sequences and privileges your application requires. Verify inserts and updates through the real application, not just SELECT COUNT(*). If a serial sequence is behind existing IDs, advance it safely based on the actual table maximum before accepting new writes. DMS validation of rows is not a guarantee that every schema object and application write path migrated.

8. Cutover: stop source writes and end CDC

My cutover sequence was: stop new writes on the Azure application, check DMS CDC lag and task/table state, confirm final target counts and representative data, test inserts/reads on RDS, then stop the DMS task once the target was authoritative. Record a rollback decision point before allowing target-side writes; once AWS accepts new writes, reversing direction is no longer simply pointing the application back to Azure.

DMS replication stopped
The DMS task after replication was stopped.

9. Move the application from Azure to a private AWS EC2 instance

Create an Ubuntu EC2 instance in a private AWS subnet; the final lab host was 10.16.2.80. Do not give the app server a public IP just to make deployment easier. Use a controlled administration path such as VPN plus SSH, SSM, or a bastion. The lab used SSH/SCP across the VPN, with the public SSH key added to the destination user's authorized_keys.

Package the application without its local dependencies or secrets:

tar --exclude=node_modules --exclude=.env -czf app.tar.gz ./app-directory scp app.tar.gz ubuntu@10.16.2.80:/home/ubuntu/ 

The commands above are templates: adapt paths and SSH access to your environment. On EC2, install the compatible Node.js runtime (Node.js 22 in this lab), unpack the app, run npm ci if a lockfile is present, and create a new environment configuration with the RDS endpoint and appropriate secret handling. In the lab I ran the application using a systemd service named migration-webapp.service.

sudo systemctl enable --now migration-webapp.service sudo systemctl status migration-webapp.service curl -i http://127.0.0.1:3000/health 

Test that /health actually reaches RDS. A static 200 OK is not enough if the application depends on the database.

10. Publish the application through an ALB, not through a public EC2 IP

In EC2 → Target groups → Create target group, choose Instances, protocol HTTP, port 3000, and health-check path /health. Register the private EC2 instance and wait for it to become healthy.

In EC2 → Load balancers → Create Application Load Balancer, choose internet-facing, select public subnets in at least two AZs, and attach an ALB security group permitting inbound HTTPS 443 (and HTTP 80 only if redirecting). Configure an HTTP :80 listener → redirect to HTTPS :443, and an HTTPS :443 listener → forward to the port-3000 target group. Attach a valid ACM certificate for the hostname users actually visit; the lab used a wildcard certificate for *.holadmex.online.

Critical security-group pairing:

Browser → ALB SG: inbound TCP 443 ALB → EC2 SG: inbound TCP 3000, source = ALB security group EC2 → RDS SG: inbound TCP 5432, source = EC2 security group 

This is where I hit a 504 Gateway Timeout. The ALB could receive requests but the EC2 security group was not allowing the ALB to reach application port 3000. Allowing TCP 3000 from the ALB security group, not from 0.0.0.0/0, resolved the traffic path. Port 443 is the client-to-ALB port; port 3000 is the ALB-to-app port. They do not need to match.

The lab ALB DNS was testing2-695509388.us-east-1.elb.amazonaws.com. A matching custom DNS record for the ACM wildcard hostname was not confirmed in the captured evidence, so I am not claiming a completed public DNS cutover.

Order-management application
Order-management application used for end-to-end read/write verification. Note: any older “Azure simulated on-prem” environment label shown in the UI was hardcoded and was not proof of where the app was running.

11. Replication checklist for another engineer

  • [ ] Azure source VM and PostgreSQL reachable privately; sample data and /health tested
  • [ ] AWS VPC/private subnets, RDS subnet group and DMS network created
  • [ ] AWS Customer Gateway points to Azure VPN public IP
  • [ ] AWS VGW attached to correct VPC; VPN connection created with Azure CIDR
  • [ ] Azure Local Network Gateway points to AWS tunnel outside IP and AWS CIDR
  • [ ] Matching tunnel PSK/IKE parameters configured; VPN status inspected on both clouds
  • [ ] AWS route to Azure CIDR and Azure route to AWS CIDR verified
  • [ ] TCP 5432 permitted end-to-end; DMS source and target endpoint tests pass
  • [ ] Full load + CDC task assessed and monitored
  • [ ] All table rows validated, plus schema objects, defaults, constraints, sequences and app writes
  • [ ] Source writes frozen; final CDC state and cutover decision recorded
  • [ ] App running on private EC2 and reading/writing private RDS
  • [ ] ALB 443 → target group 3000 → EC2; /health healthy; SG source restrictions correct
  • [ ] Rollback and cleanup plans recorded; lab resources deleted when no longer needed

12. What I would improve for production

These are future improvements, not claims that they were deployed in this lab: redundant healthy VPN tunnels and failover testing; Terraform for repeatable infrastructure; Secrets Manager; properly verified RDS TLS; stronger observability and alarms; backups and recovery drills; database schema migrations under version control; CI/CD; containerizing the app for ECS/Fargate or EKS; and a formally rehearsed rollback/cutover plan.

13. Reusable configuration and application code templates

The console walkthrough above records the completed lab. The snippets below are adaptable starter templates, not a claim that I originally provisioned every component using code. Substitute your own resource IDs, CIDRs, tunnel configuration, certificates and credentials. Do not paste secrets or preshared keys into DEV.to or GitHub.

A. AWS network and VPN: AWS CLI template

Create the VPC and subnets through the console as described earlier, or manage them with Terraform. The following illustrates the VPN-related AWS API sequence after you have a VPC and know the Azure VPN gateway's public IP. It uses a Virtual Private Gateway (VGW), rather than a Transit Gateway.

export AWS_REGION=us-east-1 export VPC_ID="vpc-REPLACE_ME" export PRIVATE_ROUTE_TABLE_ID="rtb-REPLACE_ME" export AZURE_VPN_PUBLIC_IP="4.255.22.3" # lab example; replace export AZURE_CIDR="172.16.0.0/16" # AWS represents the Azure VPN gateway as a Customer Gateway. CGW_ID=$(aws ec2 create-customer-gateway \ --type ipsec.1 --public-ip "$AZURE_VPN_PUBLIC_IP" \ --bgp-asn 65000 --region "$AWS_REGION" \ --query 'CustomerGateway.CustomerGatewayId' --output text) VGW_ID=$(aws ec2 create-vpn-gateway \ --type ipsec.1 --region "$AWS_REGION" \ --query 'VpnGateway.VpnGatewayId' --output text) aws ec2 attach-vpn-gateway \ --vpn-gateway-id "$VGW_ID" --vpc-id "$VPC_ID" \ --region "$AWS_REGION" # Static routing: no BGP in this lab. VPN_ID=$(aws ec2 create-vpn-connection \ --type ipsec.1 --customer-gateway-id "$CGW_ID" \ --vpn-gateway-id "$VGW_ID" \ --options '{"StaticRoutesOnly":true}' \ --region "$AWS_REGION" \ --query 'VpnConnection.VpnConnectionId' --output text) aws ec2 create-vpn-connection-route \ --vpn-connection-id "$VPN_ID" \ --destination-cidr-block "$AZURE_CIDR" \ --region "$AWS_REGION" # This is a DIFFERENT route from the VPN connection's static route. aws ec2 create-route \ --route-table-id "$PRIVATE_ROUTE_TABLE_ID" \ --destination-cidr-block "$AZURE_CIDR" \ --gateway-id "$VGW_ID" --region "$AWS_REGION" aws ec2 describe-vpn-connections --vpn-connection-ids "$VPN_ID" \ --query 'VpnConnections[0].VgwTelemetry[*].[OutsideIpAddress,Status,StatusMessage]' \ --output table 

Important: Download the generated AWS VPN vendor configuration and use its exact tunnel outside IPs, inside IPs, IKE/IPsec settings, and preshared keys when configuring Azure. AWS usually provisions two tunnels; the lab had one UP and the other DOWN. A production design should establish redundant tunnels and test failover. Depending on your existing route tables and propagation settings, avoid adding a duplicate route.

B. Azure VPN setup: CLI template and dependency order

Azure VPN Gateway provisioning is asynchronous and can take considerable time. The CLI below is an illustrative baseline, not a drop-in substitute for the AWS-provided tunnel parameters. The Azure gateway public IP must be available before creating the AWS Customer Gateway; you can create the Azure side first, then the AWS side, then finish the Azure connection.

RG=rg-onprem-migration-lab LOCATION=eastus VNET=private-vnet AWS_TUNNEL1_PUBLIC_IP="3.227.45.240" # lab example; replace from AWS VPN config az network vnet create -g "$RG" -n "$VNET" -l "$LOCATION" \ --address-prefixes 172.16.0.0/16 \ --subnet-name private-subnet --subnet-prefixes 172.16.0.0/24 az network vnet subnet create -g "$RG" --vnet-name "$VNET" \ -n GatewaySubnet --address-prefixes 172.16.255.0/24 az network public-ip create -g "$RG" -n azure-vpn-pip \ --sku Standard --allocation-method Static az network vnet-gateway create -g "$RG" -n onprem-vpn-gateway \ --public-ip-addresses azure-vpn-pip --vnet "$VNET" \ --gateway-type Vpn --vpn-type RouteBased --sku VpnGw1AZ az network local-gateway create -g "$RG" \ -n aws-local-network-gateway \ --gateway-ip-address "$AWS_TUNNEL1_PUBLIC_IP" \ --local-address-prefixes 10.16.0.0/16 # Set this in your shell securely; never commit it or publish its value. read -rs VPN_PSK az network vpn-connection create -g "$RG" -n azure-to-aws-tunnel1 \ --vnet-gateway1 onprem-vpn-gateway \ --local-gateway2 aws-local-network-gateway \ --shared-key "$VPN_PSK" unset VPN_PSK az network vpn-connection show -g "$RG" \ -n azure-to-aws-tunnel1 --query connectionStatus -o tsv 

For a working connection, match the AWS tunnel's actual IKE/IPsec proposals, lifetime, traffic selectors and PSK. If you configure a custom IPsec/IKE policy, ensure both ends agree. Allow the required private traffic in Azure NSGs, AWS security groups and any OS firewalls.

C. PostgreSQL source schema and sample dataset

Run this as a database administrator against migrationdb on the source. This sample schema reproduces the table types used in the exercise; it is not an export of the exact original schema.

CREATE TABLE IF NOT EXISTS customers ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, price NUMERIC(10,2) NOT NULL CHECK (price >= 0) ); CREATE TABLE IF NOT EXISTS orders ( id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), product_id INTEGER NOT NULL REFERENCES products(id), quantity INTEGER NOT NULL CHECK (quantity > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); INSERT INTO customers(name,email) VALUES ('Ada Example','ada@example.invalid'), ('Sam Example','sam@example.invalid') ON CONFLICT (email) DO NOTHING; SELECT 'customers' AS table_name, count(*) FROM customers UNION ALL SELECT 'products', count(*) FROM products UNION ALL SELECT 'orders', count(*) FROM orders; 

DMS caveat: Full load + CDC primarily moves table data and changes. Do not assume that it has created sequences, defaults, foreign keys, unique constraints, grants or other schema objects. Create the target schema with an appropriate PostgreSQL-native schema migration or a reviewed SQL migration before full load, taking care that constraints and table preparation settings are compatible with DMS. Verify sequence values after load:
...