A practical walkthrough of VNets, private subnets, load balancers, and the debugging moments nobody talks about.
Introduction
There is a version of cloud deployment that looks like this: spin up a VM, paste your code, open all ports, and call it done. It works. But it is not how production systems are built.
When I set out to deploy the Book Review App — a full-stack Next.js and Node.js application backed by MySQL — I wanted to do it the right way. Three isolated tiers. Private subnets. No direct database exposure. Real load balancers. The kind of architecture you would find in an actual engineering team.
This post walks through exactly how I built it on Microsoft Azure, the problems I ran into, and what each one taught me.
Architecture Overview
Before writing a single command, I designed the network. Here is the full architecture:
Internet
│
▼
[ Public Load Balancer ] — web-public-lb
│ port 80
▼
Azure Virtual Network (bookreview-vnet — 10.0.0.0/16)
│
├── Availability Zone 1 Availability Zone 2
│ web-subnet-1 (10.0.1.0/24) web-subnet-2 (10.0.2.0/24)
│ VM: web-vm-1 VM: web-vm-2
│ Next.js + Nginx (port 80)
│ │ port 3001
│ ▼
│ [ Internal Load Balancer ] — app-internal-lb
│ │
│ app-subnet-1 (10.0.3.0/24) app-subnet-2 (10.0.4.0/24)
│ VM: app-vm-1 VM: app-vm-2
│ Node.js/Express (port 3001)
│ │ port 3306
│ ▼
│ db-subnet-1 (10.0.5.0/24) db-subnet-2 (10.0.6.0/24)
│ Azure MySQL Flexible Server
│ Private VNet Integration only
│ No public IP
Three tiers. Six subnets. Two availability zones. Each tier locked down by its own Network Security Group.
The NSG rules enforced a strict hierarchy:
- Web NSG — allows port 80 from anywhere (users)
- App NSG — allows port 3001 from web subnets only (10.0.1.0/24, 10.0.2.0/24)
- DB NSG — allows port 3306 from app subnets only (10.0.3.0/24, 10.0.4.0/24)
Even if someone compromised the web tier, they still could not reach the database directly. That is defence in depth.
The Web Tier — Next.js Behind Nginx
The web VM sits in the public subnet with a public IP. It runs two things: Nginx on port 80 as the entry point, and Next.js on port 3000 as the application.
After cloning the repo and installing dependencies, the build was straightforward:
cd ~/book-review-app/frontend
npm install
npm run build
npm run start &
The Nginx configuration proxies all incoming traffic to Next.js, and routes /api calls to the internal load balancer:
server {
listen 80;
server_name _;
location /api {
proxy_pass http://INTERNAL_LB_PRIVATE_IP:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
This means users never interact with Next.js or the backend directly — they only ever see Nginx. In production, this is also where you would handle SSL termination and rate limiting.
The App Tier — Node.js With No Public IP
This is where the architecture gets interesting.
The app VM sits in a private subnet with no public IP at all. It is completely unreachable from the internet. The only way traffic reaches it is through the internal load balancer, which itself only accepts connections from the web subnets.
To SSH into it, I had to use the web VM as a jump host:
# From my laptop → web VM
ssh azureuser@WEB_VM_PUBLIC_IP
# From web VM → app VM (private IP only)
ssh azureuser@10.0.3.5
This is the jump host pattern — the web VM acts as a controlled gateway into the private network. It is a standard security practice in cloud environments.
Once inside, the setup was familiar: clone the repo, navigate to the backend folder, configure the environment, and start the server.
cd ~/book-review-app/backend
npm install
node src/server.js &
The backend connects to the Azure MySQL server using Sequelize with SSL enforced:
const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
host: DB_HOST,
dialect: "mysql",
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
}
});
The Database Tier — Private and Managed
The database is an Azure MySQL Flexible Server deployed with Private access (VNet Integration). This places it inside the private subnet with a private IP address. No public hostname. No internet exposure whatsoever.
Azure handles the infrastructure, patching, and backups. I only needed to connect and set up the schema.
From the app VM:
mysql -h bookreview-db.mysql.database.azure.com \
-u azureuser -p --ssl-mode=REQUIRED
CREATE DATABASE bookreviews;
EXIT;
Sequelize then handled the rest — syncing the models and seeding sample data on first startup.
The Problems I Actually Hit
This is the part no deployment guide covers properly. Here is what went wrong and how I fixed each one.
1. DB_PASSWORD vs DB_PASS — A Silent Auth Failure
The backend .env file had:
DB_PASSWORD=MyPassword
But src/config/db.js read:
process.env.DB_PASS
The result: the backend started, tried to connect to MySQL, and got:
Access denied for user 'azureuser'@'10.0.3.5' (using password: NO)
Using password: NO. The password was never being read at all. Once I renamed the variable to DB_PASS in the .env, the connection succeeded immediately.
Lesson: Always read the actual code before assuming the environment variable names. Documentation lies. Code does not.
2. The Frontend Was Not Reaching the Backend
The app loaded but showed "No books available." The frontend was running, the backend was running, but they were not talking to each other.
I checked src/services/api.js:
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
The fallback was localhost:3001 — which on the web VM points to nothing. The backend runs on a completely different machine.
The fix was creating a .env.local file in the frontend:
NEXT_PUBLIC_API_URL=http://WEB_VM_PUBLIC_IP
Combined with the Nginx /api proxy rule, this routes all API calls through Nginx to the internal load balancer to the app VM. The frontend only ever talks to its own Nginx — clean and correct.
3. EADDRINUSE — Port Already in Use
Starting the server a second time without stopping the first:
Error: listen EADDRINUSE: address already in use :::3001
Fix:
sudo kill $(lsof -t -i:3001)
node src/server.js &
Same issue appeared on port 3000 for the frontend. Same fix.
4. ChunkLoadError After Rebuild
After rebuilding the Next.js app, the browser threw a ChunkLoadError because it had cached the old build's JavaScript filenames. The new build generated different filenames, so the old cached references returned 400 errors.
Fix: hard refresh with Ctrl+Shift+R. The browser fetched the new files and the error disappeared.
What This Project Taught Me
Network design comes first. Before the VMs. Before the app. Before anything. The network determines what can talk to what — and getting it wrong at the start creates problems that are painful to fix later.
Private subnets are not optional for databases. A database with a public IP is an incident waiting to happen. Private VNet integration is the baseline, not an advanced feature.
Environment variables deserve the same attention as code. A single character difference between DB_PASSWORD and DB_PASS caused a silent auth failure that took real debugging to find. Treat your .env files carefully.
The jump host pattern is essential knowledge. When a VM has no public IP, you need a controlled path to reach it. Using the web VM as a jump host is a standard practice — understanding it is part of operating in any serious cloud environment.
Read the actual code. Every guide, including the official ones, makes assumptions about your project. The folder structure, the environment variable names, the port numbers — always verify them against the actual code before running anything.
Final Architecture Summary
Internet → Public LB → Nginx (port 80) → Next.js (port 3000)
→ Internal LB → Node.js (port 3001)
→ Azure MySQL (port 3306)
Private subnet only
No public IP
Three tiers. Zero unnecessary exposure. Every component reachable only by the component that needs it.
That is what production-grade means.
If you are building something similar or have questions about any part of this architecture, drop a comment below. I would genuinely like to hear how others are approaching three-tier deployments on Azure.
Tech stack: Microsoft Azure · Ubuntu 24.04 · Next.js 15 · Node.js 18 · Express.js · MySQL 8.0 · Nginx 1.24 · Sequelize ORM
Tags: azure devops cloud webdev node
















Top comments (0)