Table of Contents
- Introduction
- Architecture Overview
- Prerequisites
- Project Setup and Directory Structure
- Part 1: Deploying Spring Boot 4.x as a Windows Service
- Part 2: Building Angular 21 for Production
- Part 3: Nginx Setup and Configuration
- Part 4: Connecting Everything Together
- Part 5: Verification and Testing
- Part 6: Maintenance and Operations
- Part 7: Adding SSL/HTTPS (Optional)
- Part 8: Complete Automated Deployment Script
- Part 9: Troubleshooting Guide
- Bonus: Split Nginx Config into Multiple Files
- Conclusion
Introduction
If you've ever Googled "deploy Spring Boot on Windows Server," you've probably found a wasteland of outdated guides targeting Spring Boot 2.x, manual startup scripts, and zero mention of how to serve your frontend properly. This guide fixes that.
We're going to deploy a real production setup with:
- Spring Boot 4.x running as a proper Windows Service (auto-starts on boot, auto-restarts on crash, logs rotate)
- Angular 21 served as static files through Nginx (fast, compressed, cached)
- Nginx acting as both the web server and reverse proxy (single entry point, no CORS headaches)
- NSSM (Non-Sucking Service Manager) managing both services
The project is called Meeting Planning Platform: meeting-planning-platform-api is the backend, meeting-planning-platform-administration is the frontend. Both are already built and ready. We just need to wire up the infrastructure.
By the end of this guide, you'll have:
- Both apps running as Windows Services that survive reboots
- A single URL that serves the frontend and proxies API calls
- Proper logging with rotation
- A one-script deployment you can reuse
Let's get to it.
Architecture Overview
Here's what we're building:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WINDOWS SERVER 2022 β
β β
β Client Request (http://your-server) β
β β β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Nginx (Port 80) β β
β β β β
β β GET / β index.html (Angular app) β
β β GET /assets/* β static files (JS, CSS, images) β
β β GET /api/* β proxy :8080 (Spring Boot) β
β β GET /ws/* β proxy :8080 (WebSocket) β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β β reverse proxy (localhost only) β
β βΌ β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Spring Boot 4.x (Port 8080) β β
β β meeting-planning-platform-api β β
β β Managed by NSSM β β
β β Java 21 + Virtual Threads β β
β βββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Why this architecture?
- Single entry point: Users hit port 80 (or 443 with SSL). One URL, one port, done.
- No CORS: Since Angular and the API are served from the same origin, the browser never triggers CORS preflight requests.
-
Security: Port 8080 is never exposed externally. Spring Boot only listens on
127.0.0.1. - Performance: Nginx handles static file serving with gzip compression and aggressive caching. Spring Boot only handles API logic.
- Reliability: Both services auto-start and auto-restart. Your app survives reboots and crashes.
Prerequisites
Software Requirements
| Software | Minimum Version | Why |
|---|---|---|
| Windows Server | 2022 | Our target OS |
| Java JDK | 21 | Spring Boot 4.x requires Java 21+ |
| Node.js | 20.x or 22.x | Angular 21 requires Node 20+ |
| Angular CLI | 21.x | For building the frontend |
| NSSM | 2.24 | Windows service manager |
| Nginx | 1.26+ | Web server and reverse proxy |
Installing Java 21
Spring Boot 4.x requires Java 21 as the minimum version (up from Java 17 in Spring Boot 3.x). This is non-negotiable.
Option A: Eclipse Adoptium (recommended for servers)
Download from https://adoptium.net/temurin/releases/?version=21&os=windows
Choose the .msi installer for easy PATH configuration.
Option B: Oracle JDK
Download from https://www.oracle.com/java/technologies/downloads/#java21
Verify installation:
java -version
Expected output:
openjdk version "21.0.4" 2024-07-16 LTS
OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.4+7 (build 21.0.4+7-LTS, mixed mode)
Also verify JAVA_HOME:
echo $env:JAVA_HOME
# Should output something like: C:\Program Files\Eclipse Adoptium\jdk-21.0.4.7-hotspot
Installing Node.js 20+
Angular 21 requires Node.js 20.x or 22.x.
Download from https://nodejs.org/ (LTS version).
node --version
# v20.x.x or v22.x.x
npm --version
# 10.x.x
Install Angular CLI globally:
npm install -g @angular/cli@21
ng version
Downloading NSSM
NSSM (Non-Sucking Service Manager) lets you run any executable as a Windows Service with logging, restart policies, and environment configuration.
- Download from https://nssm.cc/release/nssm-2.24.zip
- Extract the ZIP
- Copy
nssm.exefrom thewin64folder toC:\nssm\nssm.exe
# Verify
C:\nssm\nssm.exe version
Downloading Nginx for Windows
- Download the stable version from https://nginx.org/en/download.html (Windows zip)
- Extract to
C:\nginx\
# Verify
C:\nginx\nginx.exe -v
# nginx version: nginx/1.26.x
Project Setup and Directory Structure
Before we deploy anything, let's establish a clean directory structure. Consistency here saves you headaches later.
# Create the full directory tree
New-Item -ItemType Directory -Force -Path "C:\apps\meeting-planning-platform-api\logs"
New-Item -ItemType Directory -Force -Path "C:\apps\meeting-planning-platform-administration\dist"
New-Item -ItemType Directory -Force -Path "C:\nginx\ssl"
New-Item -ItemType Directory -Force -Path "C:\nssm"
Final structure:
C:\
βββ apps\
β βββ meeting-planning-platform-api\
β β βββ meeting-planning-platform-api.jar β Your compiled Spring Boot app
β β βββ application.properties β External config (optional)
β β βββ logs\ β Application and service logs
β βββ meeting-planning-platform-administration\
β βββ dist\ β Angular production build output
βββ nginx\
β βββ nginx.exe
β βββ conf\
β β βββ nginx.conf β Our custom configuration
β βββ logs\ β Nginx access and error logs
β βββ ssl\ β SSL certificates (when ready)
β βββ html\ β Default error pages
βββ nssm\
βββ nssm.exe β Service manager
Why C:\apps\ instead of C:\Program Files\?
Permissions. Program Files has restrictive ACLs that can cause issues with log writing and config file access. A dedicated C:\apps\ directory keeps things simple and avoids UAC headaches.
Part 1: Deploying Spring Boot 4.x as a Windows Service
Step 1: Build the JAR
On your development machine (or CI/CD pipeline), build the production JAR:
# Using Maven
./mvnw clean package -DskipTests -Pprod
# Using Gradle
./gradlew bootJar -x test
The output JAR will be in:
- Maven:
target/meeting-planning-platform-api-0.0.1-SNAPSHOT.jar - Gradle:
build/libs/meeting-planning-platform-api-0.0.1-SNAPSHOT.jar
Copy it to the server:
# On the server, or via SCP/RDP file transfer
Copy-Item "meeting-planning-platform-api-0.0.1-SNAPSHOT.jar" `
-Destination "C:\apps\meeting-planning-platform-api\meeting-planning-platform-api.jar"
Tip: Rename the JAR to a consistent name without the version number. This way, your NSSM service config doesn't need updating with every release.
Step 2: External Configuration
Spring Boot 4.x automatically loads application.properties (or application.yml) from the same directory as the JAR. This lets you override embedded configuration without rebuilding.
Create C:\apps\meeting-planning-platform-api\application.properties:
# ==============================================================
# Meeting Planning Platform API - Production Configuration
# ==============================================================
# --- Server ---
server.port=8080
server.address=127.0.0.1
# Binding to 127.0.0.1 ensures only Nginx (on the same machine) can reach it.
# External clients must go through Nginx on port 80.
# --- Database ---
spring.datasource.url=jdbc:postgresql://localhost:5432/meeting_platform
spring.datasource.username=app_user
spring.datasource.password=your_secure_password_here
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
# --- JPA ---
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
# --- Virtual Threads (Spring Boot 4.x) ---
# Enables Project Loom virtual threads for all request handling.
# Dramatically improves throughput for I/O-bound operations.
spring.threads.virtual.enabled=true
# --- Logging ---
logging.file.name=C:/apps/meeting-planning-platform-api/logs/application.log
logging.level.root=INFO
logging.level.com.yourcompany.meetingplatform=DEBUG
logging.file.max-size=50MB
logging.file.max-history=30
logging.file.total-size-cap=1GB
# --- Actuator (health checks) ---
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=when-authorized
# --- Jackson ---
spring.jackson.serialization.write-dates-as-timestamps=false
spring.jackson.default-property-inclusion=non_null
What's new in Spring Boot 4.x that matters here:
| Feature | Impact |
|---|---|
| Java 21 baseline | Virtual threads, pattern matching, record patterns |
| Jakarta EE 11 | All javax.* packages are now jakarta.*
|
| Virtual threads |
spring.threads.virtual.enabled=true replaces thread pools |
| Observability | Built-in Micrometer tracing (if you add the dependency) |
| Structured logging | JSON log output available natively |
Step 3: Test the Application Manually
Always test before installing as a service. If it fails interactively, it'll definitely fail as a service, and service logs are harder to read.
cd C:\apps\meeting-planning-platform-api
# Run directly
java -Xms512m -Xmx2048m -jar meeting-planning-platform-api.jar
Watch the console output. You should see:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.x.x)
...
Started MeetingPlanningPlatformApiApplication in 3.456 seconds
Test the health endpoint:
# In another terminal
Invoke-WebRequest -Uri "http://localhost:8080/actuator/health" -UseBasicParsing
Expected response:
{"status":"UP"}
Press Ctrl+C to stop the app once you've confirmed it works.
Step 4: Install as a Windows Service with NSSM
Now we install it as a service so it runs in the background, starts on boot, and restarts on failure.
# Run PowerShell as Administrator
# Find your Java path
$javaPath = (Get-Command java).Source
Write-Host "Using Java at: $javaPath"
# Example: C:\Program Files\Eclipse Adoptium\jdk-21.0.4.7-hotspot\bin\java.exe
Install the service:
# Create the service
C:\nssm\nssm.exe install MeetingPlanningAPI "$javaPath"
# Set the JVM arguments and JAR path
C:\nssm\nssm.exe set MeetingPlanningAPI AppParameters `
"-Xms512m -Xmx2048m -jar C:\apps\meeting-planning-platform-api\meeting-planning-platform-api.jar"
# Set working directory (so Spring Boot finds application.properties)
C:\nssm\nssm.exe set MeetingPlanningAPI AppDirectory `
"C:\apps\meeting-planning-platform-api"
# Display name and description (shows in services.msc)
C:\nssm\nssm.exe set MeetingPlanningAPI DisplayName "Meeting Planning Platform API"
C:\nssm\nssm.exe set MeetingPlanningAPI Description `
"Spring Boot 4.x REST API for Meeting Planning Platform"
# Auto-start on boot
C:\nssm\nssm.exe set MeetingPlanningAPI Start SERVICE_AUTO_START
What each parameter does:
| Parameter | Value | Explanation |
|---|---|---|
Application |
java.exe path |
The executable to run |
AppParameters |
JVM args + JAR path | Arguments passed to java.exe |
AppDirectory |
JAR directory | Working directory (Spring Boot looks here for config) |
Start |
SERVICE_AUTO_START |
Starts when Windows boots |
Step 5: Configure Logging and Rotation
Without log rotation, your disk will fill up. NSSM can capture stdout/stderr and rotate files automatically.
# Redirect stdout and stderr to files
C:\nssm\nssm.exe set MeetingPlanningAPI AppStdout `
"C:\apps\meeting-planning-platform-api\logs\service-stdout.log"
C:\nssm\nssm.exe set MeetingPlanningAPI AppStderr `
"C:\apps\meeting-planning-platform-api\logs\service-stderr.log"
# Enable log rotation
C:\nssm\nssm.exe set MeetingPlanningAPI AppStdoutCreationDisposition 4
C:\nssm\nssm.exe set MeetingPlanningAPI AppStderrCreationDisposition 4
C:\nssm\nssm.exe set MeetingPlanningAPI AppRotateFiles 1
C:\nssm\nssm.exe set MeetingPlanningAPI AppRotateOnline 1
# Rotate when file exceeds 10MB
C:\nssm\nssm.exe set MeetingPlanningAPI AppRotateBytes 10485760
How rotation works:
-
AppRotateFiles 1: Enables rotation -
AppRotateOnline 1: Rotates while the service is running (not just on restart) -
AppRotateBytes 10485760: Triggers rotation at 10MB
Old log files get a timestamp suffix: service-stdout.log becomes service-stdout-20260802T084700.log.
Step 6: Configure Auto-Restart and Recovery
This is the reason we use NSSM over sc.exe. NSSM handles crashes gracefully.
# If the app exits (crashes), restart it
C:\nssm\nssm.exe set MeetingPlanningAPI AppExit Default Restart
# Wait 10 seconds before restarting (prevents rapid crash loops)
C:\nssm\nssm.exe set MeetingPlanningAPI AppRestartDelay 10000
# Graceful shutdown: give the app 30 seconds to finish requests
C:\nssm\nssm.exe set MeetingPlanningAPI AppStopMethodSkip 0
C:\nssm\nssm.exe set MeetingPlanningAPI AppStopMethodConsole 30000
C:\nssm\nssm.exe set MeetingPlanningAPI AppStopMethodWindow 30000
C:\nssm\nssm.exe set MeetingPlanningAPI AppStopMethodThreads 30000
# Set environment variables
C:\nssm\nssm.exe set MeetingPlanningAPI AppEnvironmentExtra `
"SPRING_PROFILES_ACTIVE=prod"
Shutdown sequence explanation:
When you stop the service, NSSM tries these methods in order:
- Console (30s): Sends Ctrl+C to the process (Spring Boot handles this gracefully)
- Window (30s): Posts WM_CLOSE to any windows
- Threads (30s): Posts WM_QUIT to all threads
- Kill: Force terminates if all else fails
This ensures Spring Boot has time to close database connections, flush logs, and complete in-flight requests.
Step 7: Start and Verify the Service
# Start the service
C:\nssm\nssm.exe start MeetingPlanningAPI
# Check status
C:\nssm\nssm.exe status MeetingPlanningAPI
# Expected: SERVICE_RUNNING
# Verify via Windows
Get-Service MeetingPlanningAPI | Format-Table Name, Status, StartType
You can also verify in the Services management console:
services.msc
# Look for "Meeting Planning Platform API"
Test the API:
# Wait a few seconds for startup, then:
Invoke-WebRequest -Uri "http://localhost:8080/actuator/health" -UseBasicParsing
If it's not running, check the logs:
type C:\apps\meeting-planning-platform-api\logs\service-stderr.log
Part 2: Building Angular 21 for Production
Step 1: Configure the API URL
This is the most important configuration decision for connecting frontend to backend.
The strategy: Use a relative URL for the API. Angular will make requests to /api/... on the same origin it was loaded from. Nginx intercepts these and proxies them to Spring Boot.
File: src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: '/api'
};
File: src/environments/environment.ts (development)
export const environment = {
production: false,
apiUrl: 'http://localhost:8080/api' // Direct for ng serve
};
In your services, use it like:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class MeetingService {
private apiUrl = `${environment.apiUrl}/meetings`;
constructor(private http: HttpClient) {}
getMeetings() {
// In production, this calls: /api/meetings
// Nginx proxies it to: http://localhost:8080/api/meetings
return this.http.get<Meeting[]>(this.apiUrl);
}
}
Why this is better than hardcoding an absolute URL:
| Approach | CORS needed? | Works on any domain? | Config changes on move? |
|---|---|---|---|
Relative /api
|
No | Yes | No |
Absolute http://server:8080
|
Yes | No | Yes |
Step 2: Production Build
Angular 21 uses the esbuild-based application builder by default (much faster than the old Webpack builder).
cd C:\path\to\meeting-planning-platform-administration
# Install dependencies
npm ci
# Build for production
ng build --configuration=production
What the build does:
- Tree-shaking (removes unused code)
- Ahead-of-Time (AOT) compilation
- Minification and bundling
- Content hashing for cache busting (e.g.,
main.a1b2c3d4.js) - Source maps (optional, disabled by default in production)
Expected output:
β Browser application bundle generation complete.
β Copying assets complete.
β Index html generation complete.
Initial chunk files | Names | Raw size | Estimated transfer size
--------------------- | --------- | -------- | -----------------------
main.a1b2c3d4.js | main | 245 kB | 62 kB
polyfills.e5f6g7h8.js | polyfills | 33 kB | 11 kB
styles.i9j0k1l2.css | styles | 48 kB | 8 kB
| Initial total | 326 kB | 81 kB
Output location: dist/meeting-planning-platform-administration/browser
Step 3: Understanding the Angular 21 Output Structure
Angular 21's application builder outputs to a browser/ subdirectory:
dist/
βββ meeting-planning-platform-administration/
βββ browser/ β THIS is what you deploy
β βββ index.html
β βββ main.a1b2c3d4.js
β βββ polyfills.e5f6g7h8.js
β βββ styles.i9j0k1l2.css
β βββ assets/
β β βββ images/
β β βββ i18n/
β βββ media/ (fonts, etc.)
β βββ 3rdpartylicenses.txt
βββ server/ β Only if using SSR (ignore for our setup)
Important: In Angular 17+, the output structure changed. If you're coming from Angular 16 or earlier, the files used to be directly in
dist/<name>/without thebrowser/subfolder.
Step 4: Deploy to the Server
# Copy the browser folder contents to the deployment directory
Copy-Item -Recurse -Force `
"dist\meeting-planning-platform-administration\browser\*" `
"C:\apps\meeting-planning-platform-administration\dist\"
Verify:
# This file must exist
Test-Path "C:\apps\meeting-planning-platform-administration\dist\index.html"
# Should output: True
# List the files
Get-ChildItem "C:\apps\meeting-planning-platform-administration\dist"
Part 3: Nginx Setup and Configuration
Why Nginx?
You might wonder: why not just let Spring Boot serve the Angular files? Three reasons:
Performance: Nginx is purpose-built for serving static files. It uses
sendfile()to bypass userspace copying. Spring Boot's embedded Tomcat is designed for dynamic content.Caching and Compression: Nginx handles gzip, brotli, cache headers, and conditional requests natively and efficiently.
Separation of Concerns: If your API crashes, the frontend still loads and can show a user-friendly error. If you need to update the frontend, you don't restart the API.
Step 1: The Complete nginx.conf File
This is the complete, production-ready configuration. Replace C:\nginx\conf\nginx.conf entirely:
# ==============================================================
# NGINX CONFIGURATION
# Meeting Planning Platform
# Angular 21 Frontend + Spring Boot 4.x API Reverse Proxy
# Windows Server 2022
# ==============================================================
worker_processes auto;
error_log logs/error.log warn;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# --------------------------------------------------------------
# LOGGING
# --------------------------------------------------------------
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
# --------------------------------------------------------------
# PERFORMANCE
# --------------------------------------------------------------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50M;
# --------------------------------------------------------------
# GZIP COMPRESSION
# --------------------------------------------------------------
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml
font/woff2;
# --------------------------------------------------------------
# UPSTREAM: Spring Boot API Backend
# --------------------------------------------------------------
upstream spring_boot_api {
server 127.0.0.1:8080;
keepalive 32;
}
# ===========================================================
# SERVER: HTTP (Port 80)
# ===========================================================
server {
listen 80;
server_name localhost your-domain.com 192.168.1.100;
# Angular App: Document Root
root C:/apps/meeting-planning-platform-administration/dist;
index index.html;
# Angular SPA Routing
location / {
try_files $uri $uri/ /index.html;
# Cache: Hashed assets (immutable)
location ~* "\.[0-9a-f]{16,}\.(js|css)$" {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Cache: Other static assets
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
}
# API Reverse Proxy
location /api/ {
proxy_pass http://spring_boot_api/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# WebSocket Support
location /ws/ {
proxy_pass http://spring_boot_api/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}
# Health Check
location /health {
proxy_pass http://spring_boot_api/actuator/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
access_log off;
}
# Error Pages
error_page 502 503 504 /50x.html;
location = /50x.html {
root html;
internal;
}
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# ===========================================================
# SERVER: HTTPS (Port 443) β Uncomment when SSL is ready
# ===========================================================
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
# ssl_certificate C:/nginx/ssl/your-domain.crt;
# ssl_certificate_key C:/nginx/ssl/your-domain.key;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# root C:/apps/meeting-planning-platform-administration/dist;
# index index.html;
# location / { try_files $uri $uri/ /index.html; }
# location /api/ { proxy_pass http://spring_boot_api/api/; }
# location /ws/ { proxy_pass http://spring_boot_api/ws/; }
# }
# ===========================================================
# HTTP β HTTPS Redirect (Uncomment when SSL is configured)
# ===========================================================
# server {
# listen 80;
# server_name your-domain.com;
# return 301 https://$server_name$request_uri;
# }
}
π§ Final NGINX Configuration for Angular + Spring Boot (with WebSocket Support) β Example from My Deployment
After iterating through several fixes β MIME type errors, API path mismatches, and WebSocket handshake failures β this is the final working NGINX configuration for deploying an Angular 21 frontend with a Spring Boot 4.x backend on Windows Server 2022.
It handles:
- Static asset caching for Angular bundles
- SPA routing with index.html fallback
- Reverse proxy for Spring Boot API (/meeting-planning-platform-api)
- WebSocket support for real-time features
- Security headers for basic hardening
βοΈ My Build & Context Setup
# Angular build with base href
ng build --base-href=/meeting-planning-platform-administration/
# Spring Boot context path (application.properties)
server.servlet.context-path=/meeting-planning-platform-api
# API version prefix in code
public static final String VERSION = "/api/v1";
This ensures that:
- Angular assets are served under /meeting-planning-platform-administration/
- Spring Boot endpoints live under /meeting-planning-platform-api/api/v1/...
- NGINX proxies requests correctly without CORS issues
π Full NGINX Config Block
# ==============================================================
# NGINX CONFIGURATION
# Meeting Planning Platform
# Angular 21 Frontend + Spring Boot 4.x API Reverse Proxy
# Windows Server 2022
# ==============================================================
worker_processes auto;
error_log logs/error.log warn;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# --------------------------------------------------------------
# LOGGING
# --------------------------------------------------------------
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
# --------------------------------------------------------------
# PERFORMANCE
# --------------------------------------------------------------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50M;
# --------------------------------------------------------------
# GZIP COMPRESSION
# --------------------------------------------------------------
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml
font/woff2;
# --------------------------------------------------------------
# UPSTREAM: Spring Boot API Backend
# --------------------------------------------------------------
upstream spring_boot_api {
server 127.0.0.1:8080;
keepalive 32;
}
# ===========================================================
# SERVER: HTTP (Port 80)
# ===========================================================
server {
listen 80;
server_name localhost your-domain.com 192.168.1.100;
# Angular App: Document Root
root C:/apps/meeting-planning-platform-administration/dist;
index index.html;
# Angular SPA Routing
location / {
try_files $uri $uri/ /index.html;
# Cache: Hashed assets (immutable)
location ~* \.(?:js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Cache: Other static assets
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
}
# API Reverse Proxy
location /meeting-planning-platform-api/ {
proxy_pass http://spring_boot_api/meeting-planning-platform-api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# WebSocket Support
location /meeting-planning-platform-api/ws/ {
proxy_pass http://spring_boot_api/meeting-planning-platform-api/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}
# Health Check
location /health {
proxy_pass http://spring_boot_api/meeting-planning-platform-api/actuator/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
access_log off;
}
# Error Pages
error_page 502 503 504 /50x.html;
location = /50x.html {
root html;
internal;
}
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# ===========================================================
# SERVER: HTTPS (Port 443) β Uncomment when SSL is ready
# ===========================================================
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
# ssl_certificate C:/nginx/ssl/your-domain.crt;
# ssl_certificate_key C:/nginx/ssl/your-domain.key;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# root C:/apps/meeting-planning-platform-administration/dist;
# index index.html;
# location / { try_files $uri $uri/ /index.html; }
# location /api/ { proxy_pass http://spring_boot_api/api/; }
# location /ws/ { proxy_pass http://spring_boot_api/ws/; }
# }
# ===========================================================
# HTTP β HTTPS Redirect (Uncomment when SSL is configured)
# ===========================================================
# server {
# listen 80;
# server_name your-domain.com;
# return 301 https://$server_name$request_uri;
# }
}
Step 2: Understanding Each Section
Let me break down the non-obvious parts:
upstream block
upstream spring_boot_api {
server 127.0.0.1:8080;
keepalive 32;
}
This defines a named backend pool. Using upstream instead of hardcoding 127.0.0.1:8080 in every proxy_pass gives you:
- A single place to change the port
- The ability to add multiple backend servers for load balancing later
- Connection pooling via
keepalive 32(reuses TCP connections instead of opening new ones per request)
try_files for SPA routing
location / {
try_files $uri $uri/ /index.html;
}
This is the single most important line for Angular. Without it, refreshing the page at /meetings/123 would return a 404 because there's no physical file at that path. try_files tells Nginx: "Look for a real file first, then fall back to index.html and let Angular's router handle it."
Caching strategy
# Hashed files: cache forever
location ~* \.[0-9a-f]{16,}\.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
Angular 21 generates filenames with content hashes (e.g., main.a1b2c3d4e5f6g7h8.js). When the code changes, the filename changes. So we can cache these files for a year because the browser will automatically request the new filename from the updated index.html.
The regex \.[0-9a-f]{16,}\. matches Angular's 16+ character hex hashes in filenames.
Proxy headers
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Since Spring Boot sits behind Nginx, it sees all requests coming from 127.0.0.1. These headers tell Spring Boot the real client IP and protocol. Spring Boot 4.x respects these automatically when you configure:
# In application.properties
server.forward-headers-strategy=framework
Step 3: Test and Start Nginx
cd C:\nginx
# Test configuration syntax
.\nginx.exe -t
Expected:
nginx: the configuration file C:\nginx/conf/nginx.conf syntax is ok
nginx: configuration file C:\nginx/conf/nginx.conf test is successful
If you get errors, the output will tell you the exact line number. Fix and re-test.
# Start Nginx
start nginx
# Verify it's running (should show 2 processes: master + worker)
tasklist /fi "imagename eq nginx.exe"
Expected:
Image Name PID Session Name Session# Mem Usage
nginx.exe 1234 Services 0 5,432 K
nginx.exe 5678 Services 0 6,789 K
Step 4: Install Nginx as a Windows Service
Running Nginx from the command line means it dies when you log out. Let's make it a proper service:
# Install via NSSM
C:\nssm\nssm.exe install NginxService "C:\nginx\nginx.exe"
C:\nssm\nssm.exe set NginxService AppDirectory "C:\nginx"
C:\nssm\nssm.exe set NginxService DisplayName "Nginx Web Server"
C:\nssm\nssm.exe set NginxService Description "Nginx reverse proxy for Meeting Planning Platform"
C:\nssm\nssm.exe set NginxService Start SERVICE_AUTO_START
C:\nssm\nssm.exe set NginxService AppStdout "C:\nginx\logs\service-stdout.log"
C:\nssm\nssm.exe set NginxService AppStderr "C:\nginx\logs\service-stderr.log"
# Start it
C:\nssm\nssm.exe start NginxService
C:\nssm\nssm.exe status NginxService
# Expected: SERVICE_RUNNING
Note on reloading config: When you edit
nginx.conf, you don't need to restart the service. Just runC:\nginx\nginx.exe -s reload. Nginx reloads configuration gracefully without dropping existing connections.
Why Install Nginx as a Windows Service
While Nginx is natively a daemon (background service) on Linux, the official Windows version is built as a standard command-line application (.exe). It does not contain the necessary internal code to communicate with the Windows Service Control Manager (SCM).
Here is why developers use NSSM (Non-Sucking Service Manager) to run Nginx on Windows:
1. True Background Execution
Without NSSM: Nginx runs inside an open Command Prompt window. If you log out of Windows, or if someone closes that terminal window, the Nginx web server immediately shuts down.
With NSSM: Nginx runs silently in the background from the moment Windows boots up, completely independent of user sessions.
2. Automatic Startup
Without NSSM: A user must log into the server and manually type nginx.exe to start the web server after every system reboot.
With NSSM: The SERVICE_AUTO_START command ensures Nginx launches automatically during the Windows boot sequence, even if no user ever logs into the machine.
3. Crash Recovery and Monitoring
Without NSSM: If Nginx crashes due to an unhandled error or a lack of system resources, it stays dead until you notice and manually restart it.
With NSSM: NSSM acts as a supervisor. It continuously monitors the nginx.exe process. If Nginx crashes, NSSM catches the failure and immediately restarts it automatically.
4. Consolidated Log Management
Standard Windows applications do not automatically log terminal output to files.
By setting AppStdout and AppStderr via NSSM, all raw startup errors and console outputs are captured into dedicated text files (service-stdout.log), making troubleshooting much easier.
a double-click or running .\nginx.exe launches the program, showing up in Task Manager does not mean it is a Windows Service.
Here is the technical difference between what you are seeing and a true service:
1. Process vs. Service
What you see (Process): When you double-click nginx.exe, it runs as a standard user application process. It is tied entirely to your current Windows login session.
What a Service is: A Windows Service is a specialized background process that hooks into the Windows Service Control Manager (SCM). It can run when no user is logged in at all.
2. The "Log Out" Test
Without NSSM: If you double-click nginx.exe and then log out of your Windows account (or lock the server and log off), Windows immediately terminates your user session. Nginx will instantly shut down, and your website/API will go offline.
With NSSM: If you use NSSM, Nginx runs under a system account. You can log out completely, and Nginx will stay online 24/7.
3. What happens during a Reboot?
Without NSSM: If the computer restarts (due to an update or power outage), Nginx will not start up until someone physically logs back into Windows and double-clicks the file again.
With NSSM: The second the computer reaches the Windows login screen, Nginx is already running in the background.
How to see the difference in Task Manager
Open your Task Manager and look at the tabs at the top:
Go to the Details tab: You will see
nginx.exelisted here. This just means the code is executing.Go to the Services tab: If you did not use NSSM, Nginx will not be listed here. Windows does not recognize it as a managed system service.
NSSM essentially acts as a middleman wrapper. Windows talks to NSSM as a service, and NSSM handles launching and protecting your nginx.exe
_On Windows, NSSM is not optional β itβs the only way to make Nginx behave like a real service.
βββββββββββββββββββββββββββββββββββββββββββββββββ
β Without NSSM (Process) β
β β
β βββββββββββββββββ β
β β nginx.exe β β Runs as user process β
β βββββββββββββββββ β
β β’ Tied to login session β
β β’ Shuts down on logout β
β β’ Not visible in Services tab β
βββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββ
β With NSSM (Service) β
β β
β βββββββββββββββββ β
β β NSSM Wrapper β β Registered with SCM β
β βββββββββββββββββ β
β β β
β βΌ β
β βββββββββββββββββ β
β β nginx.exe β β Managed background task β
β βββββββββββββββββ β
β β’ Autoβstart on boot β
β β’ Survives logout/reboot β
β β’ Visible in Services tab β
βββββββββββββββββββββββββββββββββββββββββββββββββ
NSSM bridges the gap β turning nginx.exe into a true Windows Service.
Part 4: Connecting Everything Together
How the Request Flow Works
Let's trace a complete request to understand how the pieces connect:
Scenario 1: User opens the app
Browser β GET http://your-server/
β Nginx (port 80)
β Matches "location /"
β Serves C:/apps/.../dist/index.html
β Browser loads Angular app
Browser β GET http://your-server/main.a1b2c3d4.js
β Nginx (port 80)
β Matches static file regex
β Serves file with Cache-Control: immutable
β Browser caches for 1 year
Scenario 2: Angular makes an API call
Angular β GET http://your-server/api/meetings
β Nginx (port 80)
β Matches "location /api/"
β proxy_pass to http://127.0.0.1:8080/api/meetings
β Spring Boot processes request
β Returns JSON
β Nginx forwards response to browser
Scenario 3: User refreshes on a deep route
Browser β GET http://your-server/meetings/123/details
β Nginx (port 80)
β Matches "location /"
β try_files: no file at /meetings/123/details
β Falls back to /index.html
β Angular router handles /meetings/123/details
Handling the API Path Prefix
There are two common scenarios:
Scenario A: Your Spring Boot controllers use /api prefix
@RestController
@RequestMapping("/api/meetings")
public class MeetingController {
// GET /api/meetings
}
In this case, the nginx config works as-is:
location /api/ {
proxy_pass http://spring_boot_api/api/;
# /api/meetings Γ’β β /api/meetings (passed through)
}
Scenario B: Your Spring Boot controllers DON'T have /api prefix
@RestController
@RequestMapping("/meetings")
public class MeetingController {
// GET /meetings
}
You have two options:
Option B1 (recommended): Add context path in Spring Boot:
server.servlet.context-path=/api
# Now /meetings becomes /api/meetings automatically
Option B2: Strip the prefix in Nginx:
location /api/ {
proxy_pass http://spring_boot_api/;
# /api/meetings Γ’β β /meetings (prefix stripped)
}
Firewall Configuration
Only port 80 (and 443 for HTTPS) should be accessible externally. Port 8080 stays internal.
# Allow HTTP traffic
New-NetFirewallRule -DisplayName "Allow HTTP Inbound" `
-Direction Inbound `
-Protocol TCP `
-LocalPort 80 `
-Action Allow `
-Profile Any
# Allow HTTPS traffic (for when you add SSL)
New-NetFirewallRule -DisplayName "Allow HTTPS Inbound" `
-Direction Inbound `
-Protocol TCP `
-LocalPort 443 `
-Action Allow `
-Profile Any
Do NOT add a rule for port 8080. The whole point of the reverse proxy is that Spring Boot is never directly exposed.
Verify:
Get-NetFirewallRule | Where-Object {$_.DisplayName -like "*HTTP*"} |
Format-Table DisplayName, Direction, Action, Enabled
Part 5: Verification and Testing
Run through this checklist after deployment:
# ==============================================================
# DEPLOYMENT VERIFICATION CHECKLIST
# ==============================================================
Write-Host "=== Deployment Verification ===" -ForegroundColor Cyan
# 1. Services running?
Write-Host "`n[1] Checking services..." -ForegroundColor Yellow
$apiStatus = C:\nssm\nssm.exe status MeetingPlanningAPI
$nginxStatus = C:\nssm\nssm.exe status NginxService
Write-Host " API Service: $apiStatus"
Write-Host " Nginx Service: $nginxStatus"
# 2. Spring Boot responding?
Write-Host "`n[2] Checking Spring Boot health..." -ForegroundColor Yellow
try {
$health = Invoke-WebRequest -Uri "http://localhost:8080/actuator/health" -UseBasicParsing
Write-Host " Status: $($health.StatusCode) - $($health.Content)" -ForegroundColor Green
} catch {
Write-Host " FAILED: $_" -ForegroundColor Red
}
# 3. Angular app served?
Write-Host "`n[3] Checking Angular app..." -ForegroundColor Yellow
try {
$page = Invoke-WebRequest -Uri "http://localhost" -UseBasicParsing
if ($page.Content -match "<app-root") {
Write-Host " Angular app is being served correctly" -ForegroundColor Green
} else {
Write-Host " WARNING: Page served but doesn't contain Angular app" -ForegroundColor Yellow
}
} catch {
Write-Host " FAILED: $_" -ForegroundColor Red
}
# 4. API proxy working?
Write-Host "`n[4] Checking API proxy..." -ForegroundColor Yellow
try {
$api = Invoke-WebRequest -Uri "http://localhost/api/actuator/health" -UseBasicParsing
Write-Host " Proxy working: $($api.Content)" -ForegroundColor Green
} catch {
Write-Host " FAILED: $_" -ForegroundColor Red
}
# 5. External access?
Write-Host "`n[5] Checking external access..." -ForegroundColor Yellow
$ip = (Get-NetIPAddress -AddressFamily IPv4 |
Where-Object {$_.InterfaceAlias -notlike "*Loopback*"}).IPAddress
Write-Host " Test from another machine: http://$ip" -ForegroundColor White
Write-Host "`n=== Verification Complete ===" -ForegroundColor Cyan
Part 6: Maintenance and Operations
Service Management Commands
Here's your cheat sheet for day-to-day operations:
# ==============================================================
# SPRING BOOT API
# ==============================================================
# Start / Stop / Restart
C:\nssm\nssm.exe start MeetingPlanningAPI
C:\nssm\nssm.exe stop MeetingPlanningAPI
C:\nssm\nssm.exe restart MeetingPlanningAPI
# Check status
C:\nssm\nssm.exe status MeetingPlanningAPI
# View current configuration
C:\nssm\nssm.exe get MeetingPlanningAPI Application
C:\nssm\nssm.exe get MeetingPlanningAPI AppParameters
C:\nssm\nssm.exe get MeetingPlanningAPI AppDirectory
# Edit configuration (opens GUI)
C:\nssm\nssm.exe edit MeetingPlanningAPI
# Uninstall (removes the service completely)
C:\nssm\nssm.exe remove MeetingPlanningAPI confirm
# ==============================================================
# NGINX
# ==============================================================
# Service control
C:\nssm\nssm.exe start NginxService
C:\nssm\nssm.exe stop NginxService
C:\nssm\nssm.exe restart NginxService
C:\nssm\nssm.exe status NginxService
# Direct Nginx commands (preferred for config changes)
cd C:\nginx
.\nginx.exe -t # Test config (always do this first!)
.\nginx.exe -s reload # Hot-reload config (zero downtime)
.\nginx.exe -s stop # Fast shutdown
.\nginx.exe -s quit # Graceful shutdown (finishes current requests)
# Uninstall
C:\nssm\nssm.exe remove NginxService confirm
Deploying Updates (Zero-Confusion Process)
Updating the Spring Boot API
# 1. Stop the service
C:\nssm\nssm.exe stop MeetingPlanningAPI
# 2. Backup the current JAR (optional but smart)
Copy-Item "C:\apps\meeting-planning-platform-api\meeting-planning-platform-api.jar" `
-Destination "C:\apps\meeting-planning-platform-api\meeting-planning-platform-api.jar.bak"
# 3. Replace with new version
Copy-Item "path\to\new-version.jar" `
-Destination "C:\apps\meeting-planning-platform-api\meeting-planning-platform-api.jar" -Force
# 4. Start the service
C:\nssm\nssm.exe start MeetingPlanningAPI
# 5. Verify
Start-Sleep -Seconds 10
Invoke-WebRequest -Uri "http://localhost:8080/actuator/health" -UseBasicParsing
Downtime: ~10-30 seconds (Spring Boot startup time).
Updating the Angular Frontend
# 1. Build new version (on dev machine or server)
cd C:\path\to\meeting-planning-platform-administration
ng build --configuration=production
# 2. Clear old files
Remove-Item -Recurse -Force "C:\apps\meeting-planning-platform-administration\dist\*"
# 3. Deploy new files
Copy-Item -Recurse -Force `
"dist\meeting-planning-platform-administration\browser\*" `
"C:\apps\meeting-planning-platform-administration\dist\"
# 4. Reload Nginx (clears any cached responses)
cd C:\nginx
.\nginx.exe -s reload
Downtime: Zero. Users on the old version continue working. New requests get the new version. The content-hashed filenames prevent caching conflicts.
Log Monitoring
# Live-tail Spring Boot application log
Get-Content "C:\apps\meeting-planning-platform-api\logs\application.log" -Tail 100 -Wait
# Live-tail Nginx access log (see all requests)
Get-Content "C:\nginx\logs\access.log" -Tail 50 -Wait
# Live-tail Nginx error log (see proxy failures, config errors)
Get-Content "C:\nginx\logs\error.log" -Tail 50 -Wait
# Check Spring Boot NSSM stderr (useful for startup crashes)
Get-Content "C:\apps\meeting-planning-platform-api\logs\service-stderr.log" -Tail 50
# Search for errors in the last hour
Get-Content "C:\apps\meeting-planning-platform-api\logs\application.log" |
Where-Object { $_ -match "ERROR" } |
Select-Object -Last 20
Part 7: Adding SSL/HTTPS (Optional)
When you're ready for HTTPS, here's what to do:
Option A: Self-Signed Certificate (Internal/Testing)
# Generate self-signed cert (PowerShell)
$cert = New-SelfSignedCertificate -DnsName "your-domain.com" `
-CertStoreLocation "Cert:\LocalMachine\My" `
-NotAfter (Get-Date).AddYears(5)
# Export to PFX
$password = ConvertTo-SecureString -String "your-password" -Force -AsPlainText
Export-PfxCertificate -Cert $cert -FilePath "C:\nginx\ssl\cert.pfx" -Password $password
# Convert PFX to PEM (Nginx format) using OpenSSL
# Download OpenSSL for Windows if needed
openssl pkcs12 -in C:\nginx\ssl\cert.pfx -out C:\nginx\ssl\your-domain.crt -nokeys
openssl pkcs12 -in C:\nginx\ssl\cert.pfx -out C:\nginx\ssl\your-domain.key -nocerts -nodes
Option B: Let's Encrypt (Public Domain)
Use win-acme for automated Let's Encrypt certificates on Windows:
# Download win-acme from https://www.win-acme.com/
# Run the interactive wizard:
wacs.exe
# It will:
# 1. Verify domain ownership
# 2. Generate certificates
# 3. Auto-renew every 60 days
Enable HTTPS in nginx.conf
Once you have certificates, uncomment the HTTPS server block in nginx.conf (shown in Part 3) and update the paths:
ssl_certificate C:/nginx/ssl/your-domain.crt;
ssl_certificate_key C:/nginx/ssl/your-domain.key;
Then reload:
cd C:\nginx
.\nginx.exe -t
.\nginx.exe -s reload
Part 8: Complete Automated Deployment Script
Save this as deploy.ps1. It handles everything from directory creation to service startup.
#Requires -RunAsAdministrator
# ==============================================================
# MEETING PLANNING PLATFORM - AUTOMATED DEPLOYMENT
# Windows Server 2022 | Spring Boot 4.x | Angular 21
# ==============================================================
param(
[switch]$SkipFirewall,
[string]$JavaPath = "",
[int]$ApiPort = 8080,
[string]$XmsMemory = "512m",
[string]$XmxMemory = "2048m"
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Write-Step($step, $total, $message) {
Write-Host "`n[$step/$total] $message" -ForegroundColor Yellow
}
function Write-Ok($message) {
Write-Host " β $message" -ForegroundColor Green
}
function Write-Fail($message) {
Write-Host " β $message" -ForegroundColor Red
}
$totalSteps = 7
Write-Host ""
Write-Host "=============================================================" -ForegroundColor Cyan
Write-Host " MEETING PLANNING PLATFORM - DEPLOYMENT" -ForegroundColor Cyan
Write-Host " Spring Boot 4.x + Angular 21 + Nginx" -ForegroundColor Cyan
Write-Host "=============================================================" -ForegroundColor Cyan
# --- Step 1: Validate Prerequisites ---
Write-Step 1 $totalSteps "Validating prerequisites..."
# (Checks for NSSM, Nginx, Java, JAR, Angular build, nginx.conf)
# --- Step 2: Create Log Directories ---
Write-Step 2 $totalSteps "Creating log directories..."
New-Item -ItemType Directory -Force -Path "C:\apps\meeting-planning-platform-api\logs" | Out-Null
Write-Ok "C:\apps\meeting-planning-platform-api\logs"
# --- Step 3: Remove Existing Services ---
Write-Step 3 $totalSteps "Checking for existing services..."
# (Stops/removes old MeetingPlanningAPI and NginxService if present)
# --- Step 4: Install Spring Boot Service ---
Write-Step 4 $totalSteps "Installing Spring Boot API service..."
# (Configures NSSM with Java, JAR, logging, restart policies)
# --- Step 5: Install Nginx Service ---
Write-Step 5 $totalSteps "Installing Nginx service..."
# (Configures NSSM with nginx.exe, logging, auto-start)
# --- Step 6: Firewall Rules ---
if (-not $SkipFirewall) {
Write-Step 6 $totalSteps "Configuring firewall rules..."
# (Adds inbound rules for HTTP/HTTPS)
} else {
Write-Step 6 $totalSteps "Skipping firewall configuration (-SkipFirewall)"
}
# --- Step 7: Start Services ---
Write-Step 7 $totalSteps "Starting services..."
C:\nssm\nssm.exe start NginxService | Out-Null
Write-Ok "Nginx started"
C:\nssm\nssm.exe start MeetingPlanningAPI | Out-Null
Write-Ok "Spring Boot API started"
Write-Host " Waiting for Spring Boot to initialize (15s)..." -ForegroundColor Gray
Start-Sleep -Seconds 15
# --- Final Verification ---
Write-Host ""
Write-Host "=============================================================" -ForegroundColor Cyan
Write-Host " VERIFICATION" -ForegroundColor Cyan
Write-Host "=============================================================" -ForegroundColor Cyan
# (Checks API health and frontend availability)
# --- Deployment Summary ---
$serverIp = (Get-NetIPAddress -AddressFamily IPv4 |
Where-Object {$_.InterfaceAlias -notlike "*Loopback*" -and $_.PrefixOrigin -ne "WellKnown"} |
Select-Object -First 1).IPAddress
Write-Host ""
Write-Host "=============================================================" -ForegroundColor Cyan
Write-Host " DEPLOYMENT COMPLETE" -ForegroundColor Green
Write-Host "=============================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host " Access URLs:" -ForegroundColor White
Write-Host " Local: http://localhost" -ForegroundColor Gray
Write-Host " Network: http://$serverIp" -ForegroundColor Gray
Write-Host " API: http://localhost/api/" -ForegroundColor Gray
Write-Host " Health: http://localhost/health" -ForegroundColor Gray
Write-Host ""
Write-Host " Service commands:" -ForegroundColor White
Write-Host " C:\nssm\nssm.exe status MeetingPlanningAPI" -ForegroundColor Gray
Write-Host " C:\nssm\nssm.exe status NginxService" -ForegroundColor Gray
Write-Host " C:\nssm\nssm.exe restart MeetingPlanningAPI" -ForegroundColor Gray
Write-Host " C:\nginx\nginx.exe -s reload" -ForegroundColor Gray
Write-Host ""
Part 9: Troubleshooting Guide
Problem: Spring Boot service won't start
Symptoms: nssm status shows SERVICE_STOPPED or SERVICE_PAUSED
Diagnosis:
# Check the error log
type C:\apps\meeting-planning-platform-api\logs\service-stderr.log
# Verify NSSM configuration
C:\nssm\nssm.exe get MeetingPlanningAPI Application
C:\nssm\nssm.exe get MeetingPlanningAPI AppParameters
C:\nssm\nssm.exe get MeetingPlanningAPI AppDirectory
Common causes:
| Cause | Fix |
|---|---|
| Wrong Java path | nssm set MeetingPlanningAPI Application "C:\correct\path\java.exe" |
| Java version too old | Spring Boot 4.x needs Java 21+. Check java -version
|
| Port 8080 in use | `netstat -ano \ |
| Database unreachable | Check connection string in application.properties |
| Out of memory | Reduce {% raw %}-Xmx or increase server RAM |
| Missing application.properties | Verify working directory: nssm get MeetingPlanningAPI AppDirectory
|
Problem: Nginx returns 502 Bad Gateway
Symptoms: The Angular app loads, but API calls return 502.
This means: Nginx is working, but it can't reach Spring Boot.
Diagnosis:
# Is Spring Boot running?
C:\nssm\nssm.exe status MeetingPlanningAPI
# Is it listening on the right port?
netstat -ano | findstr :8080
# Should show: TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING
# Check Nginx error log for details
type C:\nginx\logs\error.log
# Look for: "connect() failed" or "no live upstreams"
Fixes:
- If Spring Boot isn't running:
nssm start MeetingPlanningAPI - If port doesn't match: check
server.portin application.properties - If bound to wrong address: ensure
server.address=127.0.0.1(not a specific IP)
Problem: Angular routes return 404
Symptoms: The home page works, but refreshing on /meetings or navigating directly to a deep URL returns 404.
This means: The try_files directive is missing or the root path is wrong.
Diagnosis:
# Does index.html exist at the configured path?
Test-Path "C:\apps\meeting-planning-platform-administration\dist\index.html"
If False, your Angular build might be in a subdirectory:
# Check for the browser subfolder (Angular 21 default)
Test-Path "C:\apps\meeting-planning-platform-administration\dist\browser\index.html"
Fix: Update nginx.conf:
# If files are in dist/browser/:
root C:/apps/meeting-planning-platform-administration/dist/browser;
Then reload:
cd C:\nginx
.\nginx.exe -t && .\nginx.exe -s reload
Problem: CORS errors in browser console
Symptoms: Access-Control-Allow-Origin errors in browser DevTools.
This should NOT happen with our setup. If it does:
Check the request URL in DevTools Network tab. If it shows
http://localhost:8080/api/...instead of/api/..., your Angular environment is using an absolute URL.Fix: Ensure
src/environments/environment.prod.tshas:
apiUrl: '/api' // Relative, NOT absolute
- Rebuild and redeploy Angular.
If you absolutely must use absolute URLs (e.g., the API is on a different server), add CORS config to Spring Boot:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://your-domain.com", "https://your-domain.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Problem: Port 80 is already in use
Symptoms: Nginx won't start, error log says "bind() to 0.0.0.0:80 failed"
Diagnosis:
netstat -ano | findstr "0.0.0.0:80"
# Note the PID in the last column
# Find what process owns it
tasklist /fi "pid eq <PID>"
Common culprits on Windows Server 2022:
| Process | Fix |
|---|---|
| IIS (w3wp.exe / W3SVC) | Stop-Service W3SVC; Set-Service W3SVC -StartupType Disabled |
| Apache (httpd.exe) | Stop and disable Apache service |
| HTTP.sys (System PID 4) |
netsh http show servicestate to see what registered the URL |
Problem: Services don't survive reboot
Diagnosis:
C:\nssm\nssm.exe get MeetingPlanningAPI Start
C:\nssm\nssm.exe get NginxService Start
# Both should output: SERVICE_AUTO_START
# If not:
C:\nssm\nssm.exe set MeetingPlanningAPI Start SERVICE_AUTO_START
C:\nssm\nssm.exe set NginxService Start SERVICE_AUTO_START
Also add Windows-native recovery:
sc.exe failure MeetingPlanningAPI reset= 86400 actions= restart/10000/restart/10000/restart/10000
sc.exe failure NginxService reset= 86400 actions= restart/10000/restart/10000/restart/10000
This tells Windows: "If the service crashes, restart it after 10 seconds. Try up to 3 times, then reset the counter after 24 hours."
Problem: Slow API responses through Nginx
Diagnosis:
# Test direct API speed
Measure-Command { Invoke-WebRequest "http://localhost:8080/api/meetings" -UseBasicParsing }
# Test through Nginx
Measure-Command { Invoke-WebRequest "http://localhost/api/meetings" -UseBasicParsing }
If there's a significant difference (>50ms overhead), check:
-
proxy_buffering on;(already in our config, enables response buffering) -
keepalive 32;in upstream (already set, reuses connections) - DNS resolution: make sure upstream uses
127.0.0.1notlocalhost(avoids DNS lookup)
πBonus: Split Nginx Config into Multiple Files
Stuffing everything into one nginx.conf works fine for a single app. But the moment you add a second site, a staging environment, or a separate admin panel, that single file turns into a wall of text that's hard to maintain and easy to break.
The solution: split each app into its own config file and let Nginx load them all through one main file using the include directive.
The Concept
Instead of this:
C:\nginx\conf\
βββ nginx.conf β 200+ lines, everything jammed together
You get this:
C:\nginx\conf\
βββ nginx.conf β Main file (global settings + includes)
βββ upstreams\
β βββ spring-boot-api.conf β Backend: Meeting Platform API
β βββ another-api.conf β Backend: Another service
βββ sites-enabled\
βββ meeting-planning-platform.conf β Site: Meeting Platform (Angular + API)
βββ admin-panel.conf β Site: Admin panel (another app)
βββ staging.conf β Site: Staging environment
Each file is independent. You can enable/disable a site by adding or removing its .conf file. No risk of breaking other sites when editing one.
Directory Setup
# Create the directories
New-Item -ItemType Directory -Force -Path "C:\nginx\conf\upstreams"
New-Item -ItemType Directory -Force -Path "C:\nginx\conf\sites-enabled"
File 1: Main nginx.conf
This file holds only global settings (performance, gzip, logging) and include directives that load everything else.
Location: C:\nginx\conf\nginx.conf
# ==============================================================
# NGINX MAIN CONFIGURATION
# Global settings + includes all site configs
# ==============================================================
worker_processes auto;
error_log logs/error.log warn;
pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# --------------------------------------------------------------
# Logging
# --------------------------------------------------------------
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
# --------------------------------------------------------------
# Performance
# --------------------------------------------------------------
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 50M;
# --------------------------------------------------------------
# Gzip
# --------------------------------------------------------------
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# --------------------------------------------------------------
# Load all upstream backend definitions
# --------------------------------------------------------------
include C:/nginx/conf/upstreams/*.conf;
# --------------------------------------------------------------
# Load all site configurations
# --------------------------------------------------------------
include C:/nginx/conf/sites-enabled/*.conf;
}
That's it. The main file never needs editing again unless you're changing global settings.
File 2: Upstream Backend Definition
Upstream blocks define your backend servers. Keeping them separate means you can reuse the same backend across multiple site configs.
Location: C:\nginx\conf\upstreams\spring-boot-api.conf
# Backend: Meeting Planning Platform API
upstream meeting_planning_api {
server 127.0.0.1:8080;
keepalive 32;
}
Adding another backend later? Just create a new file:
Location: C:\nginx\conf\upstreams\notification-service.conf
# Backend: Notification microservice
upstream notification_service {
server 127.0.0.1:9090;
keepalive 16;
}
File 3: Site Configuration
Each site (app) gets its own file with a complete server block.
Location: C:\nginx\conf\sites-enabled\meeting-planning-platform.conf
# ==============================================================
# SITE: Meeting Planning Platform
# Angular 21 frontend + Spring Boot 4.x API proxy
# ==============================================================
server {
listen 80;
server_name localhost your-domain.com;
root C:/apps/meeting-planning-platform-administration/dist;
index index.html;
# --------------------------------------------------------------
# Angular SPA Routing
# --------------------------------------------------------------
location / {
try_files $uri $uri/ /index.html;
# Cache: Hashed assets (immutable)
location ~* \.[0-9a-f]{16,}\.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Cache: Other static assets
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
}
# --------------------------------------------------------------
# API Reverse Proxy
# --------------------------------------------------------------
location /api/ {
proxy_pass http://meeting_planning_api/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# --------------------------------------------------------------
# WebSocket
# --------------------------------------------------------------
location /ws/ {
proxy_pass http://meeting_planning_api/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}
# --------------------------------------------------------------
# Health Check
# --------------------------------------------------------------
location /health {
proxy_pass http://meeting_planning_api/actuator/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
access_log off;
}
# --------------------------------------------------------------
# Error Pages
# --------------------------------------------------------------
error_page 502 503 504 /50x.html;
location = /50x.html {
root html;
internal;
}
# --------------------------------------------------------------
# Security Headers
# --------------------------------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
Example: Adding a Second App
Say you later deploy an admin dashboard on port 3000. Just create a new file:
Location: C:\nginx\conf\upstreams\admin-dashboard.conf
upstream admin_dashboard {
server 127.0.0.1:3000;
keepalive 16;
}
Location: C:\nginx\conf\sites-enabled\admin-dashboard.conf
server {
listen 80;
server_name admin.your-domain.com;
location / {
proxy_pass http://admin_dashboard/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
Reload Nginx, done. Zero changes to the main config or the meeting platform config.
cd C:\nginx
.\nginx.exe -t # Validate ALL config files
.\nginx.exe -s reload
How include Works
The include directive is simple: Nginx reads the specified path and pastes the file contents at that location during config parsing. It supports:
| Pattern | What it does |
|---|---|
include file.conf; |
Loads one specific file |
include folder/*.conf; |
Loads all .conf files in that folder (alphabetical order) |
include folder/**/*.conf; |
NOT supported on Windows (no recursive glob) |
Important behaviors:
- If
include folder/*.confmatches zero files, Nginx throws an error. To avoid this, always have at least one.conffile in each included directory, or use a specific file include instead of a wildcard. - Files are loaded in alphabetical order. If load order matters (it rarely does), prefix with numbers:
01-first.conf,02-second.conf. - Syntax errors in ANY included file will prevent Nginx from starting. Always run
nginx -tbefore reloading.
Enabling / Disabling Sites
Want to temporarily disable a site without deleting its config?
Method 1: Rename the file extension:
# Disable
Rename-Item "C:\nginx\conf\sites-enabled\staging.conf" "staging.conf.disabled"
# Enable
Rename-Item "C:\nginx\conf\sites-enabled\staging.conf.disabled" "staging.conf"
# Reload
cd C:\nginx && .\nginx.exe -s reload
Method 2: Use a sites-available / sites-enabled pattern (Linux-style):
# Create both directories
New-Item -ItemType Directory -Force -Path "C:\nginx\conf\sites-available"
New-Item -ItemType Directory -Force -Path "C:\nginx\conf\sites-enabled"
# Store all configs in sites-available
# Create symlinks in sites-enabled for active ones
# Enable a site (create symlink)
New-Item -ItemType SymbolicLink `
-Path "C:\nginx\conf\sites-enabled\meeting-planning-platform.conf" `
-Target "C:\nginx\conf\sites-available\meeting-planning-platform.conf"
# Disable a site (remove symlink)
Remove-Item "C:\nginx\conf\sites-enabled\staging.conf"
Note: Symbolic links on Windows require Administrator privileges or Developer Mode enabled.
Validation
After setting up the split config, always test:
cd C:\nginx
# Test all included files for syntax errors
.\nginx.exe -t
# Expected output:
# nginx: the configuration file C:\nginx/conf/nginx.conf syntax is ok
# nginx: configuration file C:\nginx/conf/nginx.conf test is successful
# If any included file has an error, it tells you exactly which file and line:
# nginx: [emerg] unknown directive "proxypass" in C:\nginx\conf\sites-enabled\meeting-planning-platform.conf:25
Benefits of This Approach
| Single-file approach | Multi-file approach |
|---|---|
| All config in one place | Each app isolated in its own file |
| Easy to accidentally break other sites | Changes to one site can't break others |
| Hard to find sections in 300+ lines | Each file is 30-60 lines, focused |
| No way to disable one site without editing | Rename or remove file to disable |
| Merge conflicts in version control | Each file is versioned independently |
| One person edits at a time | Multiple devs can work on different sites |
Final Directory Structure
C:\nginx\conf\
βββ nginx.conf β Global settings + includes
βββ mime.types β Default (don't edit)
βββ upstreams\
β βββ spring-boot-api.conf β Backend: port 8080
β βββ notification-service.conf β Backend: port 9090
βββ sites-enabled\
βββ meeting-planning-platform.conf β Site: main app
βββ admin-dashboard.conf β Site: admin panel
βββ staging.conf.disabled β Site: disabled (won't load)
This pattern scales from one app to dozens. No refactoring needed. Just add files.
Conclusion
You now have a production-grade deployment running on Windows Server 2022:
- Spring Boot 4.x runs as a Windows Service that auto-starts, auto-restarts on failure, and logs with rotation
- Angular 21 is served as compressed, cached static files through Nginx
- Nginx ties it all together: single entry point, reverse proxy, zero CORS, security headers
- Both services are managed by NSSM and survive reboots
The total resource footprint is minimal: Nginx uses about 5MB of RAM, and your Spring Boot app uses whatever you set in -Xmx (we used 2GB).
For your next steps:
- Add SSL certificates for HTTPS
- Set up a CI/CD pipeline that builds and deploys automatically
- Configure monitoring (Spring Boot Actuator + Prometheus/Grafana)
- Set up log aggregation if you have multiple servers
The config files in this guide are production-tested. Copy them, adjust the paths, and you're live.
Found this helpful? Follow me for more deployment guides and full-stack development content.
π More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
π Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
π LinkedIn β Professional discussions, architecture breakdowns, and engineering insights.
πΈ Instagram β Visuals, carousels, and designβdriven posts under the Terminal Elite aesthetic.
π§ Website β Articles, tutorials, and project showcases.
π₯ YouTube β Deepβdive videos and live coding sessions.
Top comments (0)