Deploying Angular + Spring Boot on IIS (Windows)

This will guide you on how to setup the deployment of an Angular SPA and a Spring Boot API run together under a single IIS site. IIS serves static Angular files directly and reverse-proxies /api/* traffic to the Spring Boot process. A scheduled watchdog task handles zero-touch JAR updates.


Architecture overview

Browser
  │
  ▼
IIS site  (port 443 / 80)
  ├── /api/*  ──► reverse proxy ──► localhost:6001/api/*   (Spring Boot)
  └── /*      ──► index.html                               (Angular SPA)

C:\inetpub\wwwroot\
  ├── YourApplication\  ← Name of you application | Windows Service | Scheduled Task(Watchdog for jar re deployments)
     ├── frontend\      ← IIS site root (Angular files + web.config)
     └── api\           ← Java service folder (JAR + WinSW + scripts)

Windows Task Scheduler
  └── "YourApplication API Watchdog"  (runs every 1 min as SYSTEM)
        └── update-api.ps1  — detects api-update.jar and hot-swaps the JAR

The Spring Boot JAR runs as a Windows service managed by WinSW. IIS never spawns or stops Java — it only forwards requests. The API folder lives beside the site root, not inside it.


Prerequisites

Step 1 — Install IIS modules

Open Server Manager → Add Roles and Features and enable:

After installing ARR, enable the global proxy feature once (run as Administrator):

& "$env:SystemRoot\system32\inetsrv\appcmd.exe" set config `
    -section:system.webServer/proxy /enabled:true

Or via IIS Manager: Application Request Routing Cache → Server Proxy Settings → Enable proxy.

Step 2 — Install Java

Download and install Amazon Corretto JDK 17+. The path used on this server:

C:\Program Files\Amazon Corretto\jdk25.0.2_10\bin\java.exe

Step 3 — Download WinSW

WinSW wraps any executable as a Windows service. Download the latest WinSW-x64.exe from the GitHub releases page. Rename it to myapp.exe (the XML config file name must match the EXE name).


Folder structure

The deployment uses two separate directories:

C:\inetpub\wwwroot\YourApplication
├── frontend\   ← IIS site root
│   ├── index.html
│   ├── main-XXXXXXXX.js
│   ├── styles-XXXXXXXX.css
│   ├── chunk-*.js
│   └── web.config
│
└── api\        ← Java service folder
    ├── myapp.jar                 ← active Spring Boot JAR
    ├── prev-myapp.jar            ← previous JAR (rollback copy)
    ├── myapp.exe                         ← WinSW binary
    ├── myapp.xml                         ← WinSW service descriptor
    ├── application.yml                   ← Spring Boot config
    ├── update-api.ps1                    ← watchdog/hot-swap script
    ├── deploy.log                        ← watchdog log
    └── logs\                             ← WinSW + app logs

Step 4 — Create the application.yml

Place application.yml in the API folder (C:\inetpub\wwwroot\YourApplication\api\). Spring Boot picks it up automatically at startup because WinSW sets the working directory to this folder.

The two keys that tie everything together:

server:
  port: 6001                    # internal port — not exposed externally
  servlet:
    context-path: /api          # must match the rewrite rule prefix in web.config

Full example (application.yml on this server):

spring:
  application:
    name: getinline-api

  datasource:
    url: jdbc:postgresql://hosanna-solutions.com:5433/getinline
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:secret}
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

  mail:
    host: ${MAIL_HOST:smtp.gmail.com}
    port: ${MAIL_PORT:587}
    username: ${MAIL_USERNAME:you@gmail.com}
    password: ${MAIL_PASSWORD:app-password}

server:
  port: 6001
  servlet:
    context-path: /api

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, loggers

logging:
  level:
    root: INFO

Step 5 — Create the WinSW service descriptor

Create myapp.xml in the same folder as myapp.exe. The file name must exactly match the EXE name.

<service>
  <id>YourApplication</id>
  <name>YourApplication</name>
  <description>YourApplication for any purpose</description>

  <executable>C:\Program Files\Amazon Corretto\jdk25.0.2_10\bin\java.exe</executable>
  <arguments>-jar "C:\inetpub\wwwroot\YourApplication\api\myapp.jar"</arguments>

  <workingdirectory>C:\inetpub\wwwroot\YourApplication\api</workingdirectory>

  <logpath>C:\inetpub\wwwroot\YourApplication\api\logs</logpath>
  <log mode="roll-by-size">
    <sizeThreshold>10240</sizeThreshold>  <!-- KB -->
    <keepFiles>8</keepFiles>
  </log>

  <startmode>Automatic</startmode>
  <stopparentprocessfirst>true</stopparentprocessfirst>

  <onfailure action="restart" delay="60000"/>
  <onfailure action="none"/>

  <resetfailure>1 hour</resetfailure>
</service>

<workingdirectory> is critical — Spring Boot resolves relative paths (like the external application.yml) from here.


Step 6 — Install and start the Windows service

Run the following as Administrator from the API folder:

cd C:\inetpub\wwwroot\YourApplication\api

.\myapp.exe install   # registers the Windows service
.\myapp.exe start     # starts it

# Verify it is running
Get-Service YourApplication

Confirm the API is up before touching IIS:

Invoke-WebRequest http://localhost:6001/api/actuator/health -UseBasicParsing

To stop or uninstall later:

.\myapp.exe stop
.\myapp.exe uninstall

Step 7 — Create the IIS site

  1. Open IIS Manager.
  2. Right-click Sites → Add Website.
  3. Set Site name to yourapplication.hosanna-solutions.com.
  4. Set Physical path to C:\inetpub\wwwroot\YourApplication\frontend\.
  5. Bind to port 80 / 443 with the hostname getinlineapp.hosanna-solutions.com.
  6. If using HTTPS, assign the SSL certificate (see the SSL Certificates guide).

No application pool changes are needed — IIS serves static files and proxies requests; it runs no managed code.


Step 8 — Deploy Angular files and create web.config

Copy the Angular production build output into the site root:

# Build locally
ng build --configuration production

# Push to the server (adjust source path to your dist folder)
robocopy .\dist\YourApplication\browser\ C:\inetpub\wwwroot\YourApplication\frontend\ /MIR /XF web.config

Then place this web.config in the site root (same folder as index.html):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <system.webServer>
        <rewrite>
            <rules>
                <!-- Rule 1: forward /api/* to the Spring Boot process -->
                <rule name="ReverseProxy to Java API" stopProcessing="true">
                    <match url="^api/(.*)" />
                    <action type="Rewrite" url="http://localhost:6001/api/{R:1}" appendQueryString="true" />
                </rule>

                <!-- Rule 2: Angular HTML5 routing — serve index.html for any
                     path that is not a real file or directory -->
                <rule name="Angular Routes" stopProcessing="true">
                    <match url=".*" />
                    <conditions logicalGrouping="MatchAll">
                        <add input="{REQUEST_FILENAME}" matchType="IsFile"      negate="true" />
                        <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
                    </conditions>
                    <action type="Rewrite" url="/index.html" />
                </rule>
            </rules>
        </rewrite>
    </system.webServer>
</configuration>

Rule order matters: /api/* is matched first and proxied to Spring Boot; everything else that is not a real file falls through to index.html so Angular's router handles it.


Step 9 — Create the watchdog update script

Create update-api.ps1 in the API folder. The scheduled task runs this every minute. When it finds api-update.jar, it stops the service, rotates the JARs, and restarts — no manual SSH needed.

# update-api.ps1
# Run via Windows Task Scheduler every minute.
# Watches for api-update.jar; when found: swaps JARs and restarts the service.

$AppDir      = "C:\inetpub\wwwroot\YourApplication\api"
$ServiceExe  = Join-Path $AppDir "myapp.exe"
$UpdateJar   = Join-Path $AppDir "api-update.jar"
$CurrentJar  = Join-Path $AppDir "myapp.jar"
$PreviousJar = Join-Path $AppDir "prev-myapp.jar"
$LogFile     = Join-Path $AppDir "deploy.log"

function Write-Log {
    param([string]$Message)
    $ts = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
    Add-Content -Path $LogFile -Value "[$ts] $Message"
}

if (-not (Test-Path $UpdateJar)) { exit 0 }

Write-Log "api-update.jar detected - starting hot-swap"

try {
    Write-Log "Stopping service..."
    & $ServiceExe stop
    Start-Sleep -Seconds 3

    if (Test-Path $PreviousJar) {
        Remove-Item $PreviousJar -Force
        Write-Log "Removed prev-myapp.jar"
    }

    if (Test-Path $CurrentJar) {
        Rename-Item -Path $CurrentJar -NewName "prev-myapp.jar" -Force
        Write-Log "Renamed myapp.jar -> prev-myapp.jar"
    }

    Rename-Item -Path $UpdateJar -NewName "myapp.jar" -Force
    Write-Log "Renamed api-update.jar -> myapp.jar"

    Write-Log "Starting service..."
    & $ServiceExe start
    Write-Log "Hot-swap complete"

} catch {
    Write-Log "ERROR: $_"

    # Rollback if the current JAR is missing
    if (-not (Test-Path $CurrentJar) -and (Test-Path $PreviousJar)) {
        Write-Log "Rolling back to prev-myapp.jar..."
        Rename-Item -Path $PreviousJar -NewName "myapp.jar" -Force
        & $ServiceExe start
        Write-Log "Rollback complete - previous version restored"
    }
    exit 1
}

Step 10 — Register the watchdog as a scheduled task

Run as Administrator. This creates the "YourApplication API Watchdog" task that runs update-api.ps1 every minute as SYSTEM with no login required.

$action  = New-ScheduledTaskAction `
    -Execute "powershell" `
    -Argument "-ExecutionPolicy Bypass -NonInteractive -File C:\inetpub\wwwroot\getinlineapp-api\update-api.ps1"

$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 1) -Once `
    -At (Get-Date)

$settings = New-ScheduledTaskSettingsSet `
    -MultipleInstances IgnoreNew `
    -ExecutionTimeLimit (New-TimeSpan -Minutes 5)

Register-ScheduledTask `
    -TaskName "YourApplication API Watchdog" `
    -Action   $action `
    -Trigger  $trigger `
    -Settings $settings `
    -RunLevel Highest `
    -User     "SYSTEM"

Verify the task exists:

Get-ScheduledTask -TaskName "YourApplication API Watchdog"

To remove it later:

Unregister-ScheduledTask -TaskName "YourApplication API Watchdog" -Confirm:$false

Deploying updates

Manual

No service restart needed. Just push new static files in frontend folder and the api-update.jar in api folder (Watchdog will run the update for you within a minute). Important to never lose the web.config file.

C:\inetpub\wwwroot\YourApplication\frontend\*
C:\inetpub\wwwroot\YourApplication\api\api-update.jar

Automatic

Take a look at ftp-deploy - Deploying files with ftp-deploy


Automated setup with setup-app.ps1

Downloads

This script replaces Steps 4–10 in a single run. It uses the folder name as the service name and only asks for the port.

Before running

Create a folder named after your app and place these three files inside it:

C:\inetpub\wwwroot\YourApplication\
  ├── application.yml       ← Spring Boot config (must have a server: block)
  ├── myapp.exe             ← WinSW binary (download above)
  └── setup-app.ps1         ← this script

Optionally include myapp.jar if you have a build ready. If not, the service is installed but not started — deploy the JAR later via the watchdog (drop api-update.jar).

Run it

# Run as Administrator from the app folder
cd C:\inetpub\wwwroot\YourApplication
.\setup-app.ps1

Single prompt:

Internal port (e.g. 8080):  6001

What it does

Step Action
1 Derives service name from the folder name (YourApplication)
2 Creates api\ and frontend\ subfolders
3 Copies application.yml and myapp.exe (and myapp.jar if present) into api\
4 Auto-detects java.exe from PATH; falls back to the Corretto default path
5 Updates server.port in api\application.yml
6 Generates api\myapp.xml (WinSW service descriptor)
7 Generates api\update-api.ps1 (watchdog hot-swap script, paths hardcoded)
8 Generates frontend\web.config (reverse proxy + Angular routing rules)
9 Installs the Windows service (uninstalls first if it already exists)
10 Starts the service if myapp.jar is present
11 Registers the YourApplication API Watchdog scheduled task (every 1 min, SYSTEM)

After the script

The resulting layout:

C:\inetpub\wwwroot\YourApplication\
  ├── application.yml          ← original (kept)
  ├── myapp.exe                ← original (kept)
  ├── setup-app.ps1      ← original (kept)
  ├── api\
  │   ├── application.yml      ← copied + server.port updated
  │   ├── myapp.exe            ← copied
  │   ├── myapp.xml            ← WinSW descriptor
  │   ├── update-api.ps1       ← watchdog script
  │   └── logs\
  └── frontend\
      └── web.config           ← IIS rewrite rules

Two remaining manual steps:

  1. IIS site — point the site's Physical Path to frontend\:

    C:\inetpub\wwwroot\YourApplication\frontend\

  2. Angular files — copy the production build into frontend\:

    powershell ng build --configuration production robocopy .\dist\my-app\ C:\inetpub\wwwroot\YourApplication\frontend\ /MIR /XF web.config


Troubleshooting

Symptom Check
502 Bad Gateway on /api/* Spring Boot not running — Get-Service YourApplication, check logs\YourApplication.out.log
Angular routes return 404 The Angular Routes rule is missing or web.config is not in the site root
Blank page / JS 404 <base href="/"> missing in Angular build, or files deployed to wrong path
Service won't start Wrong java.exe path in myapp.xml, or port already in use (netstat -ano \| findstr :6001)
ARR proxy not working ARR proxy not enabled — run the appcmd command from Step 1
Watchdog not triggering Check task status: Get-ScheduledTask "YourApplication API Watchdog", check deploy.log for errors
Watchdog ran but service failed Check logs\YourApplication.out.log; the previous JAR is kept as prev-myapp.jar for manual rollback