Skip to content

Windows Server

Tested on Windows Server 2019 and 2022.

If you haven’t already, consider Docker. It requires less server configuration and is the recommended deployment approach. Windows Server is a supported alternative for environments where containers aren’t an option.

Before copying files to the server, configure Next.js to produce a standalone output. Edit web/next.config.ts and add output: 'standalone' to the nextConfig object:

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

Set production values for all NEXT_PUBLIC_* variables. These are baked into the browser bundle at build time and must be present before the build runs. Then build:

Terminal window
npm --prefix web ci
npm --prefix web run build

This produces three folders to copy to the server:

FolderWhat it contains
web/.next/standalone/The app and its minimal dependencies. Contains a server.js entry point.
web/.next/static/Compiled CSS, JS, and fonts. Goes inside <deploy-folder>/.next/static/.
web/public/Static assets (favicon, images, etc.).
  1. Install Node.js 22 (or newer) from nodejs.org using the MSI installer.

  2. Install IIS (Windows’ built-in web server). On Windows Server, add it from Server Manager → Add roles and features → Web Server (IIS).

  3. Install the two IIS add-on modules that let IIS act as a reverse proxy. Install them in this order: ARR depends on URL Rewrite being present first.

    1. URL Rewrite: www.iis.net/downloads/microsoft/url-rewrite
    2. Application Request Routing (ARR): www.iis.net/downloads/microsoft/application-request-routing

    Each page has an Install this extension button that downloads an MSI. Run the URL Rewrite installer first, then the ARR one.

  4. Install NSSM from nssm.cc. NSSM lets you run any program as a Windows Service, so your app starts automatically on boot and restarts if it crashes.

  1. On the server, create a deployment folder (e.g. C:\apps\my-app\). Copy the contents of web\.next\standalone\ (not the folder itself) into that root, then add the static and public folders:

    C:\apps\my-app\
    ├── server.js
    ├── package.json
    ├── node_modules\
    ├── .next\server\
    ├── .next\static\ ← from web\.next\static\
    ├── public\ ← from web\public\
    └── .env ← create this on the server
  2. Create C:\apps\my-app\.env and fill in production values for every variable from web/.env.example.

  3. Register the app as a Windows Service. The standalone server.js doesn’t auto-load .env files, so use Node 22’s built-in --env-file flag to pass the values:

    Terminal window
    nssm install MyApp "C:\Program Files\nodejs\node.exe" "--env-file=C:\apps\my-app\.env" "C:\apps\my-app\server.js"
    nssm set MyApp AppDirectory "C:\apps\my-app"
    nssm set MyApp AppEnvironmentExtra +NODE_ENV=production +PORT=3000 +HOSTNAME=127.0.0.1
    New-Item -ItemType Directory -Force -Path "C:\apps\my-app\logs" | Out-Null
    nssm set MyApp AppStdout "C:\apps\my-app\logs\stdout.log"
    nssm set MyApp AppStderr "C:\apps\my-app\logs\stderr.log"
    nssm set MyApp AppRotateFiles 1
    nssm set MyApp AppRotateBytes 10485760
    nssm start MyApp

    HOSTNAME=127.0.0.1 means the Node process only accepts connections from IIS. Nothing outside the server can reach port 3000 directly.

  4. Set up IIS as the reverse proxy — see the next section. This is the last step, but it has several parts, so it’s broken out below.

IIS accepts HTTPS on port 443 and forwards every request to the Node service on port 3000. Do these parts in order. Everything here is done in IIS Manager (Start → search “IIS”).

Every IIS site needs a physical home folder, even though this one serves no files from disk. All traffic is forwarded to Node. This folder is also where the site’s web.config lives. Keep it separate from the Node app folder so IIS settings and app files don’t mix:

Terminal window
New-Item -ItemType Directory -Force -Path "C:\apps\my-app-site" | Out-Null

In the Connections pane on the left, right-click Sites → Add Website… and fill in:

FieldValue
Site nameAnything, e.g. my-app
Physical pathThe folder from step 1 — C:\apps\my-app-site
Binding typehttps
Port443
Host nameYour public hostname, e.g. app.example.com
SSL certificateYour TLS certificate

The https binding requires a certificate. If you don’t have one yet, see TLS certificates below — win-acme can add the port-443 binding and certificate to this site for you. In that case choose binding type http / port 80 for now and come back after running win-acme.

The IIS Manager Add Website dialog with the fields above filled in
The Add Website dialog. The physical path points at the empty site home folder from step 1, not the Node app folder.

3. Turn on ARR’s proxy engine (once per server)

Section titled “3. Turn on ARR’s proxy engine (once per server)”

ARR’s proxy is off by default. Forgetting this step is the most common reason the proxy appears to do nothing: the rewrite rule in step 6 then fails silently. Turn it on once:

  1. Click the server node at the top of the Connections pane (it’s named after your machine, not after a site).
  2. Double-click Application Request Routing Cache.
  3. In the Actions pane, click Server Proxy Settings….
  4. Tick Enable proxy, then click Apply.
Server Proxy Settings with Enable proxy ticked
Enable proxy under the server node's Application Request Routing Cache → Server Proxy Settings. This is set on the server node, not the site, and only needs doing once.

4. Preserve the original hostname (once per server)

Section titled “4. Preserve the original hostname (once per server)”

So your app sees the real hostname (app.example.com) instead of 127.0.0.1, tell ARR to pass the original Host header through:

  1. With the server node still selected, double-click Configuration Editor.
  2. In the Section dropdown, choose system.webServer/proxy.
  3. Set preserveHostHeader to True, then click Apply.
Configuration Editor showing preserveHostHeader set to True
Configuration Editor with the system.webServer/proxy section selected and preserveHostHeader set to True.

The rewrite rule in step 6 sets two request headers, X-Forwarded-Proto and X-Forwarded-For. IIS ignores server variables it hasn’t been told to allow. Add them to the allowed list:

  1. Select your site in the Connections pane.
  2. Double-click URL Rewrite.
  3. In the Actions pane, click View Server Variables…, then Add… each of these:
    • HTTP_X_FORWARDED_PROTO
    • HTTP_X_FORWARDED_FOR
The URL Rewrite Allowed Server Variables list with the two forwarded headers added
The two forwarded-header variables added under URL Rewrite → View Server Variables.

6. Add the rule that forwards traffic to Node

Section titled “6. Add the rule that forwards traffic to Node”

This rule does the actual proxying, and it lives in the site’s web.config (C:\apps\my-app-site\web.config). IIS doesn’t create that file by adding a site. It writes a web.config into the site folder only when you change a site-level setting through the UI. Two ways to create it:

Option A — let IIS write the file for you (no XML editing). With your site selected, double-click URL Rewrite → Add Rule(s)… → Blank rule, then set:

  • Name: ProxyToNode
  • Pattern: (.*)
  • Action type: Rewrite; Rewrite URL: http://127.0.0.1:3000/{R:1}
  • Tick Stop processing of subsequent rules, then click Apply.

Clicking Apply creates the web.config for you. To also send the forwarded headers, reopen the file and add the <serverVariables> block shown in Option B.

Option B — create the file by hand. Create a file named web.config in the site’s home folder (C:\apps\my-app-site\) and paste:

<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="ProxyToNode" stopProcessing="true">
<match url="(.*)" />
<action type="Rewrite" url="http://127.0.0.1:3000/{R:1}" />
<serverVariables>
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

This file holds only the rewrite rule. Enabling the proxy and preserving the host header are server-level settings (steps 3–4), not site-level ones, so they deliberately don’t belong here. Putting a <proxy enabled="true" preserveHostHeader="true" /> element in this file can trigger an HTTP 500.19 error (“configuration section is locked at a parent level”) on some servers, which is why those settings live at the server level instead.

X-Forwarded-Proto is hardcoded to https in the rule above. This is safe only because the site is bound to port 443 alone. If you add a port 80 binding to redirect plain HTTP to HTTPS, put that redirect on a separate IIS site so this proxy rule never runs against plaintext requests. Otherwise the app thinks every request was HTTPS and may set secure cookies on insecure connections.

IIS doesn’t ship with a Let’s Encrypt client. The common options:

  • win-acme (free, recommended): a CLI that fetches Let’s Encrypt certificates and installs them into IIS bindings. Run wacs.exe interactively the first time. It validates your domain, installs the certificate, binds it to port 443, and registers a scheduled task to auto-renew every 60 days.
  • Certify The Web: a GUI alternative. Free for up to five certificates.
  • Commercial CA or internal PKI: import the issued .pfx via IIS Manager → Server Certificates → Import, then bind it to your site on port 443. Renewal is manual; set a reminder well before the expiry date.

Make sure renewal is automated and monitored. An expired certificate breaks the site for every user at once.

Open your site in a browser. It should load over HTTPS without a port number.

Terminal window
Get-Service MyApp # is the Node process running?
Get-Content "C:\apps\my-app\logs\stderr.log" -Tail 50 # any errors?

IIS Manager’s Failed Request Tracing logs are useful for diagnosing proxy-level errors.

If your project uses cookie-based authentication, read the cookie domains section before going to production.