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:
- Windows Installer (Electron) β GUI desktop app, runs in the user session, terminates its own TLS if given a cert, and cannot run at the same time as another process using the same user data folder (it takes an exclusive lock). Good for a quick local test, bad for a proxied, service-based setup.
- Node.js package β headless, no bundled Node; you run it with a Node.js runtime you install yourself. Sits behind a reverse proxy on plain HTTP internally, letting Caddy handle real TLS certificates. This is the recommended choice for a proxied production setup and is what this guide uses.
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.
- Download the Node.js LTS installer (v24.x) from https://nodejs.org
- 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.
- Verify installation in a new terminal:
node --version
3. Install Foundry VTT (Node.js package)
- Log in to your Foundry VTT account β Purchased Licenses.
- Download the Node.js build (not "Windows Installer") for your Foundry version.
- Extract the
.zipto a permanent folder, e.g.C:\FoundryVTT\.- Depending on the packaging,
main.jsmay end up directly atC:\FoundryVTT\main.js, or nested underresources\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 clientwarnings while extracting the zip'snode_modules\.binfolder. These are harmless and can be ignored β they don't affect running the server.
- Depending on the packaging,
- Test it manually first:
cd C:\FoundryVTT node main.js --dataPath="C:\Users\<you>\AppData\Local\foundryuserdata" --port=30000 - Confirm you see
Server started and listening on port 30000in the console, and that http://localhost:30000` loads the Foundry setup screen. - 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
}
sslCert/sslKeyshould benullβ do not configure Foundry to terminate its own TLS with a self-signed certificate. Self-signed certs show browser warnings to players and require manual renewal; let Caddy handle real Let's Encrypt certificates instead.proxySSL: true+proxyPort: 443tells Foundry that although it's listening on plain HTTP onport, the outside world is really reaching it over HTTPS on 443 via the proxy (fixes websocket/asset URL generation).- The admin access key/password lives in
Config/admin.txt. If you inherit an old install and forget the password, deleteadmin.txtfrom the data directory to reset it (Foundry will prompt you to set a new one).
5. AWS Route 53 β DNS
- 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. - 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.
- 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
- 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).
- 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). - 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
}
- Replace
your-subdomain.your-domain.comwith your actual hostname (must match the Route 53 A record and Foundry'shostnameoption). reverse_proxy localhost:30000must match Foundry's configuredport.- Validate before running:
caddy validate --config C:\Caddy\Caddyfile caddy fmt --overwrite C:\Caddy\Caddyfile - First run obtains a certificate automatically from Let's Encrypt via ACME (no manual cert steps needed) β just make sure ports 80/443 are free and correctly forwarded before starting Caddy, or issuance will fail.
- Caddy's local cert/account state lives at
%APPDATA%\Caddy(i.e.C:\Users\<you>\AppData\Roaming\Caddy). If you ever get stuck ACME renewal errors from a stale/corrupted cache, stopping Caddy and deleting that folder forces a clean re-issuance on next start.
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
Recommended: Delayed Auto-Start
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
- Logs:
C:\FoundryVTT\logs\andC:\Caddy\logs\(auto-rotated at 10MB by NSSM's config above). - Restarting after a Foundry/world update:
Restart-Service FoundryVTT. - Restarting Caddy after editing the Caddyfile:
Restart-Service Caddy(orcaddy reload --config C:\Caddy\Caddyfilefor a graceful reload without dropping connections, if running Caddy interactively). - Do not run the Electron/Windows Installer build against the same data path while the Node.js service is running β Foundry locks the data directory to one process at a time and the Electron app will fail to start with "directory already locked by another process."
- Uninstalling a service (if ever needed):
Stop-Service FoundryVTT; nssm remove FoundryVTT confirm Stop-Service Caddy; nssm remove Caddy confirm
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:
- Your session size/duration is modest (a handful of participants, a few hours at a time) β resource usage is trivial for any modern PC/connection.
- You have a real public IPv4 address (no CGNAT). Compare your router's WAN IP (usually visible at its admin page, commonly
http://<gateway-ip>β find your gateway withGet-NetIPConfiguration) against what an external "what's my IP" service reports (e.g.Invoke-WebRequest -Uri https://api.ipify.org). If they match exactly, you're not behind CGNAT and direct UDP media will work fine. If they differ (especially a100.64.0.0/10address on the router side), you're behind CGNAT and would need LiveKit's TURN/TCP relay mode instead of direct UDP β more setup complexity, not covered here.
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).
13.8 Real connectivity test (recommended before going further)
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:
- Download
lkfrom the livekit-cli releases page (asset likelk_<version>_windows_amd64.zip). - 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 - In the browser tab that opens, confirm you can enable mic/camera without errors.
- 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): |
- Reset meeting room ID β forces a fresh AV room ID; only needed if you hit stuck/stale AV state.
- Enable debug logging / Enable LiveKit trace logging β verbose browser-console logging of AV/ICE internals.