Overview

A while ago during the Covid crisis, I decided to host my roleplaying game (RPG) remotely and chose the Foundry VTT system to do that. The major upside for using this software was that it came with a one-time fee that was, and still is very modest ($50.00 USD). The major downside was that once you owned the software you had to host it.
There are lots of options for hosting, but my goal has been to self-host the site with the only cost being the registration of the domain name. I have accomplished that and have a working system that operates well for my particular use case: running a game once per week for about 4 hours for up to half a dozen players.
The guide below outlines how to configure a Windows 11 PC for this use case, optionally including a LiveKit server to handle the audio.

Setup Guide

This document describes how to self-host Foundry Virtual Tabletop natively on Windows 11 (no WSL/Linux/virtualization required), reverse-proxied through Caddy with a trusted Let's Encrypt certificate, on your own domain via AWS Route 53 β€” running persistently as Windows services that survive reboot and logout.
This setup replaces an earlier WSL1/Ubuntu-based hosting approach. Running natively on Windows avoids WSL's I/O/network virtualization overhead and the WSL2 "shifting internal IP" problem, while giving the same architecture: Foundry (Node.js) ⇄ Caddy (reverse proxy + TLS) ⇄ Internet.

1. Prerequisites

Component Requirement
OS Windows 11
Foundry VTT license An active license with access to the Node.js build download (not the Windows Installer/Electron build β€” see note below)
Domain A domain you own, delegated to AWS Route 53
Router Ability to forward ports 80 and 443 to the Windows machine
Disk space Enough for your world/assets data, plus headroom for Foundry's automatic backups

Why the Node.js package, not the Windows Installer (Electron)?

Foundry offers two Windows-compatible builds:

If you used the Electron installer to test locally at some point, that's fine β€” just don't run it at the same time as the Node.js service against the same data folder (Foundry will refuse to start with a "directory already locked" error).

2. Install Node.js

Foundry VTT v14 requires Node.js v24 or higher.

  1. Download the Node.js LTS installer (v24.x) from https://nodejs.org
  2. Run the installer.
    • You do not need to check "Automatically install the necessary tools" (Chocolatey/Python/Visual Studio Build Tools) β€” that's only for compiling native addons from source, which Foundry doesn't require.
  3. Verify installation in a new terminal:
    node --version
    

3. Install Foundry VTT (Node.js package)

  1. Log in to your Foundry VTT account β†’ Purchased Licenses.
  2. Download the Node.js build (not "Windows Installer") for your Foundry version.
  3. Extract the .zip to a permanent folder, e.g. C:\FoundryVTT\.
    • Depending on the packaging, main.js may end up directly at C:\FoundryVTT\main.js, or nested under resources\app\main.js. Check which structure you got before writing the run command.
    • You may see Cannot create symbolic link: A required privilege is not held by the client warnings while extracting the zip's node_modules\.bin folder. These are harmless and can be ignored β€” they don't affect running the server.
  4. Test it manually first:
    cd C:\FoundryVTT
    node main.js --dataPath="C:\Users\<you>\AppData\Local\foundryuserdata" --port=30000
    
  5. Confirm you see Server started and listening on port 30000 in the console, and that http://localhost:30000` loads the Foundry setup screen.
  6. Stop it (Ctrl+C) once confirmed β€” it'll run as a service later.

Choosing/creating a data path

If migrating from an existing install (e.g. WSL/Linux), copy the entire user data directory over, preserving structure:

<dataPath>/
β”œβ”€β”€ Config/         (options.json, license.json, admin.txt, etc.)
β”œβ”€β”€ Data/           (worlds, systems, modules, assets β€” your live content)
β”œβ”€β”€ Backups/        (Foundry's own automatic world backup archives)
└── Logs/

Don't forget the Backups folder β€” it's easy to overlook since it's not part of the "live" world data, but it holds Foundry's own snapshot archives and can be sizeable (tens of GB). Copy it along with Data/Config/Logs.
If copying from WSL, you can access the distro's filesystem directly via:

robocopy "\\wsl$\<distro-name>\home\<user>\foundryuserdata" "C:\Users\<you>\AppData\Local\foundryuserdata" /E /MT:8

4. Configure Foundry (Config/options.json)

Key settings for a reverse-proxied setup β€” Foundry stays on plain HTTP internally; Caddy handles all real TLS:

{
  "port": 30000,
  "upnp": true,
  "hostname": "your-subdomain.your-domain.com",
  "sslCert": null,
  "sslKey": null,
  "proxySSL": true,
  "proxyPort": 443,
  "updateChannel": "stable",
  "world": null
}

5. AWS Route 53 β€” DNS

  1. In Route 53, create/confirm an A record for the hostname you're using (e.g. foundry.your-domain.com) pointing at your public IP address.
  2. If your public IP is not static, set up a dynamic DNS update mechanism (e.g. a scheduled script updating the Route 53 record, or a router with built-in DDNS support) so the record stays current.
  3. No special Route 53 configuration is needed beyond the A record β€” Caddy handles certificate issuance itself via the HTTP-01/TLS-ALPN-01 challenge over ports 80/443, so no DNS-01/API-based ACME plugin is required for the standard Caddy build.

6. Router / Firewall β€” Port Forwarding

  1. Port forward external ports 80 and 443 to the Windows machine's LAN IP (assign it a static/reserved LAN IP in your router first to avoid DHCP address changes breaking the forward).
  2. Windows Defender Firewall: allow inbound connections on ports 80 and 443 for caddy.exe (usually auto-prompted the first time Caddy runs; add manually if not).
  3. Port 30000 (Foundry's internal port) does not need to be forwarded externally β€” only Caddy (80/443) should be internet-facing. Foundry only needs to be reachable from Caddy on localhost.

7. Install Caddy

winget install CaddyServer.Caddy --accept-source-agreements --accept-package-agreements

Verify:

caddy version

8. Configure Caddy (Caddyfile)

Create C:\Caddy\Caddyfile:

{
    servers {
        protocols h1 h2
    }
}

your-subdomain.your-domain.com {
    reverse_proxy localhost:30000
    encode zstd gzip
}

Test manually before wiring up as a service:

caddy run --config C:\Caddy\Caddyfile

Confirm https://your-subdomain.your-domain.com loads Foundry with a trusted (no-warning) certificate.

9. Install NSSM (service manager)

Both Foundry and Caddy need to run as real Windows services β€” not just console windows β€” so they survive reboot and user logout.

winget install NSSM.NSSM --accept-source-agreements --accept-package-agreements

10. Create the Windows services

Run the following in an elevated (Administrator) PowerShell window β€” service installation requires admin rights.

New-Item -ItemType Directory -Path C:\FoundryVTT\logs -Force | Out-Null
New-Item -ItemType Directory -Path C:\Caddy\logs -Force | Out-Null

$nodePath = (Get-Command node).Source
$caddyPath = (Get-Command caddy).Source

# --- FoundryVTT service ---
nssm install FoundryVTT $nodePath
nssm set FoundryVTT AppParameters '"C:\FoundryVTT\main.js" --dataPath="C:\Users\<you>\AppData\Local\foundryuserdata" --port=30000'
nssm set FoundryVTT AppDirectory "C:\FoundryVTT"
nssm set FoundryVTT AppStdout "C:\FoundryVTT\logs\foundry-out.log"
nssm set FoundryVTT AppStderr "C:\FoundryVTT\logs\foundry-err.log"
nssm set FoundryVTT AppRotateFiles 1
nssm set FoundryVTT AppRotateOnline 1
nssm set FoundryVTT AppRotateBytes 10485760
nssm set FoundryVTT Start SERVICE_AUTO_START
nssm set FoundryVTT DisplayName "Foundry VTT"
nssm set FoundryVTT Description "Foundry Virtual Tabletop Node.js server"

# --- Caddy service ---
nssm install Caddy $caddyPath
nssm set Caddy AppParameters 'run --config C:\Caddy\Caddyfile'
nssm set Caddy AppDirectory "C:\Caddy"
nssm set Caddy AppStdout "C:\Caddy\logs\caddy-out.log"
nssm set Caddy AppStderr "C:\Caddy\logs\caddy-err.log"
nssm set Caddy AppRotateFiles 1
nssm set Caddy AppRotateOnline 1
nssm set Caddy AppRotateBytes 10485760
nssm set Caddy Start SERVICE_AUTO_START
nssm set Caddy DisplayName "Caddy Reverse Proxy"
nssm set Caddy Description "Caddy web server / reverse proxy for Foundry VTT"
nssm set Caddy DependOnService FoundryVTT   # Caddy starts after Foundry

# --- Start both ---
Start-Service FoundryVTT
Start-Sleep -Seconds 5
Start-Service Caddy

Get-Service FoundryVTT, Caddy | Select-Object Name, Status, StartType

To avoid boot-time race conditions (services starting before networking is fully up), set both to delayed-start:

sc.exe config FoundryVTT start= delayed-auto
sc.exe config Caddy start= delayed-auto

(Get-Service/StartType still displays "Automatic" for delayed-start services β€” this is a cosmetic limitation of that cmdlet, the delayed setting is still applied under the hood; verify with sc.exe qc <ServiceName> if you want to confirm.)

11. Verify everything end-to-end

# Services running and set to auto-start
Get-Service FoundryVTT, Caddy | Select-Object Name, Status, StartType

# Correct ports bound
Get-NetTCPConnection -LocalPort 80,443,30000 -State Listen |
    Select-Object LocalPort, OwningProcess

# Site reachable with a trusted cert
Invoke-WebRequest -Uri "https://your-subdomain.your-domain.com" -UseBasicParsing

Then reboot the machine and re-run the same checks β€” both services should auto-start with no manual intervention, and the site should be reachable within a minute or two of boot.

12. Ongoing operations notes

13. (Optional) Self-Hosted LiveKit for Audio/Video

Foundry's built-in AV can use LiveKit for voice/video chat. By default this points at LiveKit's hosted cloud service, which has a free-tier monthly bandwidth cap. For a small, regular group (e.g. up to ~6 concurrent users, a few hours a week), self-hosting your own LiveKit server alongside Foundry on the same Windows machine is easily within reach and removes the usage cap entirely.

13.1 Is self-hosting right for you?

Self-hosting works well when:

13.2 DNS

Add another Route 53 A record (e.g. livekit.your-domain.com) pointing to the same public IP as your Foundry record.

13.3 Router / Firewall β€” additional ports

On top of the 80/443 forward already set up for Caddy, forward these two additional ports to the Windows machine's LAN IP:

Protocol Port Purpose
TCP 7881 ICE/TCP fallback (used when a client can't connect via UDP)
UDP 7882 ICE/UDP mux β€” all WebRTC media multiplexed over this single port
Port 7880 (LiveKit's internal HTTP/WebSocket signaling) does not need forwarding β€” like Foundry's port, it stays behind Caddy.

Using LiveKit's single-port UDP mux (rtc.udp_port) instead of the
default wide ICE port range (rtc.port_range_start/rtc.port_range_end,
typically 50000–60000) is strongly recommended for a home router β€” one
port to forward instead of thousands.

Windows Firewall inbound rules for livekit-server.exe are normally
auto-created the first time you run it manually; verify with:

Get-NetFirewallRule -DisplayName "LiveKit*"

13.4 Install LiveKit server

Download the Windows build from the LiveKit releases page (asset named like livekit_<version>_windows_amd64.zip) and extract to C:\LiveKit\.

13.5 Generate API credentials

LiveKit uses an API key/secret pair (not tied to any external identity provider) to authorize server/client access. Generate your own random pair β€” do not reuse the example values below in a real deployment:

$apiKey = -join ((1..20) | ForEach-Object { [char]((97..122) + (48..57) | Get-Random) })
$apiSecret = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))
"API Key: $apiKey"
"API Secret: $apiSecret"

13.6 Configure LiveKit (livekit.yaml)

Create C:\LiveKit\livekit.yaml:

port: 7880
bind_addresses:
  - "0.0.0.0"

rtc:
  tcp_port: 7881
  udp_port: 7882
  use_external_ip: true

keys:
  <your-api-key>: <your-api-secret>

logging:
  level: info

Test manually before wiring up as a service:

cd C:\LiveKit
.\livekit-server.exe --config C:\LiveKit\livekit.yaml

Confirm http://localhost:7880 returns OK, and the console log shows it resolved your correct external IP via STUN (found external IP via STUN ... externalIP: <your public IP>).

13.7 Add LiveKit to the Caddyfile

Add a second site block to your existing C:\Caddy\Caddyfile:

livekit.your-domain.com {
	reverse_proxy localhost:7880
}

Caddy's reverse_proxy automatically handles the WebSocket upgrade needed for LiveKit's signaling connection β€” no extra configuration required. Validate and reload:

caddy validate --config C:\Caddy\Caddyfile
caddy reload --config C:\Caddy\Caddyfile --address localhost:2019

Then confirm https://livekit.your-domain.com returns OK with a trusted certificate (Caddy will obtain one automatically on first request, same as for Foundry).

Use LiveKit's CLI (lk) to generate a test token and open LiveKit's hosted Meet test app, connecting to your server over the real public domain β€” this proves the UDP media path (not just HTTP signaling) actually works:

  1. Download lk from the livekit-cli releases page (asset like lk_<version>_windows_amd64.zip).
  2. Generate a token and auto-open the test app:
    .\lk.exe token create `
      --api-key <your-api-key> --api-secret <your-api-secret> `
      --url wss://livekit.your-domain.com `
      --join --room test-room --identity test-user --valid-for 1h `
      --open meet
    
  3. In the browser tab that opens, confirm you can enable mic/camera without errors.
  4. Check the server logs for real media activity (confirms actual RTP flow, not just a signaling handshake):
    Select-String -Path C:\LiveKit\logs\livekit-err.log -Pattern "participant active","mediaTrackSubscribed","mediaTrackPublished"
    

13.9 Create the Windows service

Run in an elevated (Administrator) PowerShell window:

New-Item -ItemType Directory -Path C:\LiveKit\logs -Force | Out-Null

nssm install LiveKit "C:\LiveKit\livekit-server.exe"
nssm set LiveKit AppParameters '--config C:\LiveKit\livekit.yaml'
nssm set LiveKit AppDirectory "C:\LiveKit"
nssm set LiveKit AppStdout "C:\LiveKit\logs\livekit-out.log"
nssm set LiveKit AppStderr "C:\LiveKit\logs\livekit-err.log"
nssm set LiveKit AppRotateFiles 1
nssm set LiveKit AppRotateOnline 1
nssm set LiveKit AppRotateBytes 10485760
nssm set LiveKit Start SERVICE_AUTO_START
nssm set LiveKit DisplayName "LiveKit Server"
nssm set LiveKit Description "Self-hosted LiveKit WebRTC SFU for Foundry VTT audio/video"

sc.exe config LiveKit start= delayed-auto

Start-Service LiveKit
Get-Service LiveKit, Caddy, FoundryVTT | Select-Object Name, Status, StartType

Note: LiveKit's Go logger (zap) writes most output to stderr by default β€” don't be surprised if livekit-out.log stays empty while livekit-err.log fills up with normal INFO-level logs; that's expected, not an error condition.

13.10 Configure Foundry to use your self-hosted LiveKit

In Foundry: Game Settings β†’ Configure Audio/Video, set AV mode to use LiveKit, then fill in:

Field Value
LiveKit Server Address livekit.your-domain.com (hostname only, no protocol prefix)
LiveKit API Key your generated API key
LiveKit Secret Key your generated API secret
Save, then join a voice/video call to test. Optional troubleshooting settings in this same panel (leave off during normal use β€” they're verbose and only useful when actively diagnosing a connection problem):