Nginx + React: How to Serve a Single Page Application Correctly (And Why Most Tutorials Get It Wrong)
By Vivian Chiamaka Okose
Tags: #nginx #react #terraform #azure #devops #spa #nodejs #cloud #beginners
There is one Nginx configuration line that every React developer deploying to a Linux server needs to know. Most tutorials skip past it without explanation. I learned it the hard way -- by understanding exactly what breaks without it.
This is the story of Assignment 3 of my Terraform series: provisioning an Azure VM with Terraform, deploying a React application on it, and configuring Nginx to serve it correctly. Along the way I hit a Node.js version incompatibility and learned why try_files $uri /index.html is not optional for SPAs.
What I Built
Using Terraform, I provisioned the following on Microsoft Azure:
- A Resource Group, Virtual Network, and Subnet
- A Network Security Group with explicit SSH and HTTP rules
- An NSG Association linking the security group to the subnet
- A Static Public IP and Network Interface
- An Ubuntu 20.04 LTS VM (Standard_D2ads_v7) at
20.90.152.5
Then on the VM itself:
- Upgraded Node.js from v10 to v18.20.8 LTS
- Installed and configured Nginx
- Cloned, personalised, and built a React application
- Deployed it to Nginx and configured SPA routing
- Verified it live in the browser with my name on the page
Eight Terraform resources. One live React application. One important Nginx lesson.
The New Infrastructure Piece: Network Security Groups
Assignment 3 introduced something Assignment 1 did not have -- an explicit firewall.
In Azure, a Network Security Group (NSG) is the resource that controls inbound and outbound traffic rules at the subnet or NIC level. Without one, your VM has no defined firewall rules. This is a security risk in production.
resource "azurerm_network_security_group" "nsg" {
name = "react-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "Allow-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-HTTP"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "nsg_assoc" {
subnet_id = azurerm_subnet.subnet.id
network_security_group_id = azurerm_network_security_group.nsg.id
}
The critical detail here is the association resource. Creating the NSG alone does nothing -- you have to explicitly link it to the subnet. This is the same pattern as AWS Route Table Associations from Assignment 2. Both clouds require an explicit linking step that is easy to forget and produces silent failures when missing.
Problem 1: Node.js Version Incompatibility
After SSH-ing into the VM, I installed Node.js and npm using the default Ubuntu apt package:
sudo apt update && sudo apt install nodejs npm git -y
node -v # v10.19.0
npm -v # 6.14.4
Running npm install produced a wall of warnings:
npm WARN notsup Unsupported engine for react-scripts@5.0.1:
wanted: {"node":">=14.0.0"} (current: {"node":"10.19.0"})
The React app requires Node.js 14 or higher. Ubuntu 20.04's default repository ships Node.js 10 because it prioritises stability. This is a real-world operational gap that catches many developers who assume the default package manager always ships current versions.
The fix: install from the NodeSource repository which ships maintained Node.js versions:
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # v18.20.8
npm -v # 10.8.2
Then clean and reinstall dependencies:
rm -rf node_modules
npm install
Clean install, no compatibility errors.
Problem 2 (That I Avoided): The SPA Routing Problem
This is the lesson I want to spend time on because it is the one most tutorials gloss over.
A React application is a Single Page Application. There is exactly one HTML file -- index.html -- and React's JavaScript router intercepts navigation events in the browser to simulate different pages without ever making a new server request.
When you type http://yourserver.com/about in the browser, here is what happens:
Without the correct Nginx config: Nginx receives a request for /about. It looks for a file called about or about/index.html in /var/www/html. That file does not exist. Nginx returns a 404. Your React app never loads.
With the correct Nginx config: Nginx receives a request for /about. It checks for the file, does not find it, falls back to index.html, and returns that. React loads, reads the URL, and renders the About component. Everything works.
The configuration that makes this happen is one directive:
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
location / {
try_files $uri /index.html;
}
error_page 404 /index.html;
}
try_files $uri /index.html means: "try to find a file matching the URI, and if you can not find one, serve index.html instead."
This is not optional. Without it, your React app works fine when users enter from the homepage, but any direct link, browser refresh, or bookmark to an inner page returns a 404. In production this is a user experience failure that can be hard to diagnose if you do not know what to look for.
This pattern is identical for Vue.js, Angular, and any other SPA framework. Learn it once, use it everywhere.
The Build and Deploy Flow
# Personalise the app
cd ~/my-react-app/src
nano App.js
# Updated: Deployed by: Vivian Chiamaka Okose | Date: 19/03/2026
# Install dependencies and build
cd ..
npm install
npm run build
# Output:
# Compiled successfully.
# 61.25 kB build/static/js/main.d869c525.js
# Deploy to Nginx web root
sudo rm -rf /var/www/html/*
sudo cp -r build/* /var/www/html/
sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 755 /var/www/html
sudo systemctl restart nginx
Then visiting http://20.90.152.5 in the browser:
Welcome to My React App
This app is running on Nginx!
Deployed by: Vivian Chiamaka Okose
Date: 19/03/2026
Infrastructure I built with Terraform. Application I deployed manually. My name on the page. Live on the internet.
The Complete main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = false
}
}
}
resource "azurerm_resource_group" "rg" {
name = "react-app-rg"
location = "UK South"
}
resource "azurerm_virtual_network" "vnet" {
name = "react-vnet"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
address_space = ["10.0.0.0/16"]
}
resource "azurerm_subnet" "subnet" {
name = "react-subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_security_group" "nsg" {
name = "react-nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
security_rule {
name = "Allow-SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-HTTP"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "azurerm_subnet_network_security_group_association" "nsg_assoc" {
subnet_id = azurerm_subnet.subnet.id
network_security_group_id = azurerm_network_security_group.nsg.id
}
resource "azurerm_public_ip" "public_ip" {
name = "react-public-ip"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "nic" {
name = "react-nic"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.public_ip.id
}
}
resource "azurerm_virtual_machine" "vm" {
name = "react-vm"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
network_interface_ids = [azurerm_network_interface.nic.id]
vm_size = "Standard_D2ads_v7"
delete_os_disk_on_termination = true
delete_data_disks_on_termination = true
os_profile {
computer_name = "react-vm"
admin_username = "azureuser"
admin_password = "P@ssw0rd1234!"
}
os_profile_linux_config {
disable_password_authentication = false
}
storage_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts-gen2"
version = "latest"
}
storage_os_disk {
name = "react-os-disk"
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = "Standard_LRS"
}
}
output "public_ip_address" {
description = "The public IP address of the VM"
value = azurerm_public_ip.public_ip.ip_address
}
Key Takeaways
NSG + Association is a two-step pattern. Creating the NSG defines the rules. The association activates them on the subnet. Both steps are required.
Ubuntu's default Node.js is outdated. Always install from NodeSource or nvm for any modern JavaScript application.
try_files $uri /index.html is mandatory for React SPAs. Without it, direct navigation and browser refreshes return 404 errors. This applies to all SPA frameworks.
npm install warnings are not errors. The deprecated and warn messages are informational. Watch for notsup messages -- those indicate genuine compatibility issues that need fixing before the build.
I am documenting my complete DevOps learning journey in public -- one deployment at a time.
GitHub: vivianokose








Top comments (0)