WoW Forever beta
Server in Docker inside an LXC container, your own certificates, launcher and port forwarding.
The WoW Forever server (beta, client 1.60.1.70450) runs in Docker inside an LXC container on a home Proxmox host. Players connect to [server address] through the launcher.
It is based on the jibay442/forever_docker repository with the forever branch of TrinityCore. The Proxmox container has the address [local IP] on the internal network.
| Docker container | What it does | Public port |
|---|---|---|
bnetserver | Battle.net login over TLS, REST for the launcher | 1119, 8081 |
worldserver | game world | 8085 |
mysql | databases auth, characters, world, hotfixes | none |
web | registration site and launcher download | 127.0.0.1:8088 only |
Requirements#
The install needs about 80 GB of disk and 8 GB of RAM. Once the client is removed, the server takes about 28 GB.
- Build
- 8 GB RAM, 4+ cores
- per the repository README
- Running
- 4 GB RAM, 2 cores
- Disk for the install
- client ~48 GB + server ~28 GB
- Disk after the install
- ~28 GB
- the client is removed
- Client
- build 1.60.1.70450
- World of Warcraft folder with
.build.info,Dataand_classic_beta_, productwow_classic_beta - Open ports (TCP)
- 1119, 8081, 8085
- Domain
- [server address]
- A record pointing to your external IP
- Player's PC
- Windows
- launcher, administrator rights for the first setup
In our case the container got 8 cores, 8 GB of RAM and 98 GB of disk.
LXC in Proxmox#
Docker won't start inside LXC without nesting enabled, so first set up the container on the Proxmox host.
Check from inside the container that it is LXC (and not a full VM):
systemd-detect-virtOn the Proxmox host (replace <CTID> with your container number), enable nesting, set 8 GB of swap and reboot the container:
pct set <CTID> --features nesting=1,keyctl=1 --swap 8192 && pct reboot <CTID>This is the only way to set swap in LXC: a swapfile can't be created inside the container. After the build you can reduce cores and memory; in LXC this applies without a reboot:
pct set <CTID> --cores 2 --memory 4096SSH keys#
Debian forbids root password login by default, so SSH needs a key. If a correct password is reported as wrong, this is why.
On the PC (PowerShell), create a separate key pair without touching your existing keys, and print the public part:
ssh-keygen -t ed25519 -f $env:USERPROFILE\.ssh\forever_vps -C "forever-vps"
Get-Content $env:USERPROFILE\.ssh\forever_vps.pubOn the server, in the container console from Proxmox, add the public key (replace PASTE_KEY with the whole line):
mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo 'PASTE_KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keysCheck the login from the PC:
ssh -i $env:USERPROFILE\.ssh\forever_vps root@[local IP]The private file forever_vps stays on the PC only.
Docker and the repository#
One command installs Docker with the official script, installs tmux, clones the repository and creates a starter .env. Everything runs as root.
apt update && apt install -y git curl tmux && curl -fsSL https://get.docker.com | sh && git clone https://github.com/jibay442/forever_docker ~/forever && cd ~/forever && cp .env.example .envCheck that Docker works inside the container (an error about runc here means nesting isn't enabled):
docker run --rm hello-worldRun long commands inside tmux so a dropped SSH session doesn't interrupt them. Detach: Ctrl+B, then D. Come back with:
tmux attach -t foreverClient to the server#
The extractor needs the whole client root (about 48 GB), so copy the entire folder. Over a gigabit network this took about 13 minutes.
In PowerShell on the PC (change the path to your own; don't create /root/wow on the server beforehand, scp creates it):
scp -i $env:USERPROFILE\.ssh\forever_vps -r "D:\Games\test" root@[local IP]:/root/wowIf the network is Wi-Fi or unstable, use WinSCP: it can resume, while scp starts over after a dropped connection.
Check on the server: the root must contain .build.info, Data and _classic_beta_, and the size must match the folder on the PC:
ls -la /root/wow && ls /root/wow/_classic_beta_ | head && du -sh /root/wow.build.info must contain version 1.60.1.70450 and product wow_classic_beta:
grep -a -E "70450|wow_classic_beta" /root/wow/.build.info | head -n 2Configure .env#
One command generates three passwords and sets the address, the client path and the build options. Replace [server address] and [local IP] with your own values. The address in ADDR ends up in the certificates and in the launcher, so it's best not to change it after gen-certs.sh.
ADDR=[server address] && P(){ tr -dc 'a-zA-Z0-9' </dev/urandom | head -c 24; } && cd ~/forever && sed -i "s|^MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=$(P)|" .env && sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=$(P)|" .env && sed -i "s|^SOAP_PASSWORD=.*|SOAP_PASSWORD=$(P)|" .env && sed -i "s|^PUBLIC_ADDRESS=.*|PUBLIC_ADDRESS=$ADDR|" .env && sed -i "s|^REALM_LOCAL_ADDRESS=.*|REALM_LOCAL_ADDRESS=[local IP]|" .env && sed -i "s|^BUILD_JOBS=.*|BUILD_JOBS=3|" .env && sed -i "s|^WOW_DIR=.*|WOW_DIR=/root/wow|" .env && sed -i "s|^HOST_UID=.*|HOST_UID=0|" .env && sed -i "s|^HOST_GID=.*|HOST_GID=0|" .env && sed -i "s|^TZ=.*|TZ=Europe/London|" .env && grep -vE '^#|^$' .env| Option | Value | Why |
|---|---|---|
PUBLIC_ADDRESS | [server address] | the address the server gives to players |
REALM_LOCAL_ADDRESS | [local IP] | players on the home network get the local address |
BUILD_JOBS | 3 | the README says about 2 GB of RAM per job, so 3 is safe with 8 GB |
WOW_DIR | /root/wow | the folder with Data and .build.info |
HOST_UID, HOST_GID | 0 | everything runs as root; with 1000 the container can't read /root |
EXTRACT_MMAPS, MMAP_PATHFINDING | 0 | mmaps take a long time to generate and can be added later |
Don't copy the passwords from the command output into chats or documents.
Fixes that were needed#
For us, two things would have broken the build and the start without intervention. Make these changes before step 7.
Patches. docker/patches/ holds fixes on top of the forever branch; the build fails on purpose if one of them no longer applies. For us 0002-classic-surname-invite-and-names (group invites and names with surnames) and 0004-classic-360-action-buttons (360 action bar slots for gamepads) didn't apply. We moved them to a backup:
mkdir -p ~/patches-backup && mv ~/forever/docker/patches/0002-classic-surname-invite-and-names.patch ~/forever/docker/patches/0004-classic-360-action-buttons.patch ~/patches-backup/If the build fails on another patch, the error has the line applying /patches/<name> and patch does not apply: move it the same way. The server starts without these patches, but the related features work as they do in the branch without the fixes.
extra_hosts. Docker accepts only IPs in extra_hosts, but docker-compose.yml puts PUBLIC_ADDRESS there, which is a domain. The start fails with invalid IP address in add-host. Change this line to REALM_LOCAL_ADDRESS, backing up the file first:
cd ~/forever && cp docker-compose.yml ~/docker-compose.yml.bak && sed -i 's|trinity.actual.battle.net:${PUBLIC_ADDRESS:-127.0.0.1}|trinity.actual.battle.net:${REALM_LOCAL_ADDRESS:-127.0.0.1}|' docker-compose.yml && grep -n 'trinity.actual.battle.net' docker-compose.ymlThe output must show ${REALM_LOCAL_ADDRESS:-127.0.0.1} after the colon. This entry is only needed so that bnetserver can resolve the name trinity.actual.battle.net at startup. After the change the file differs from the repository, so git pull may conflict: in that case restore the backup and run the command again.
Certificates, build, data, start#
The whole chain runs as one command inside tmux. The longest part, compiling TrinityCore, took us about 20 minutes with three jobs.
tmux new -s foreverInside tmux:
cd ~/forever && ./scripts/gen-certs.sh && docker compose build && docker compose run --rm extractor && docker compose up -d && docker compose logs -f worldserverWhat happens, in order:
gen-certs.shcreates your root CA and the server certificate in~/forever/certs.docker compose buildbuilds theforever-trinitycoreandforever-webimages.extractorreads the client and puts the data in~/forever/data; the output must end withall doneand the sizes ofdbc,maps,vmaps.docker compose up -dstarts the containers; on first start worldserver fills the databases (a few minutes).
Ready means the worldserver log has the line World initialized, then ready.... Leave the logs with Ctrl+C; the containers keep running. Check:
cd ~/forever && docker compose psAll four containers must be Up, and mysql and web also healthy.
Log noise to ignore: TransmogOutfitSlotOption.db2: skipping ID ... and Received not handled opcode .... DBErrors.log may weigh about 150 MB after the first start.
Account and cleanup#
The game login is a Battle.net account, so its name must look like an email. A real address isn't needed; the server sends no mail.
Enter the worldserver console:
cd ~/forever && docker compose attach worldserverIn the console (change the email and the password; a password without quotes and spaces is best):
bnetaccount create mail@example.com PasswordLeave the console with Ctrl+P, then Ctrl+Q. Not Ctrl+C: that stops worldserver. You can create the same kind of account on the registration site, which listens on 127.0.0.1:8088.
Once the data has been extracted, the client on the server is no longer needed. Make sure the data is in place first, then delete:
ls ~/forever/data && du -sh ~/forever/data && rm -rf /root/wow && df -h /data must contain cameras, dbc, gt, maps, vmaps (about 716 MB for us). Mmaps weren't created, so mobs move without terrain navigation. You can add them later, but the client has to be copied to the server again for that.
DNS and router ports#
Players from the internet need two things: a DNS record for your external IP and three TCP ports forwarded to the container.
| Port (TCP) | Service | Purpose |
|---|---|---|
| 1119 | bnetserver | Battle.net login over TLS |
| 8081 | bnetserver | REST: launcher login, game ticket |
| 8085 | worldserver | the game itself |
- In DNS, create an A record for
[server address]pointing to your external IP (if your provider changes the IP, use DDNS). - On the router, forward ports 1119, 8081 and 8085 to
[local IP], with the same external and internal ports. - Pin this address to the container in the router's DHCP, otherwise forwarding breaks when the address changes.
Don't open 3306 (MySQL), 7878 (SOAP) or 3443 (remote access) to the outside.
Check from the PC (PowerShell):
nslookup [server address]
Test-NetConnection [server address] -Port 1119
Test-NetConnection [server address] -Port 8081
Test-NetConnection [server address] -Port 8085All three ports must show TcpTestSucceeded : True. Many routers have no NAT loopback, so from the home network the check by domain may fail even though it works for everyone else. From inside, use the launcher's Home network mode with the address [local IP].
Launcher and connecting#
The client connects to the name trinity.actual.battle.net over TLS, so it can't reach the server without the launcher or manual PC setup, and there's no realmlist to edit.
What our custom launcher does when you press Play:
- Writes the portal
trinity.actual.battle.net:1119to a separate game config and leaves the mainConfig.wtfalone. - Resolves the domain from the "Server" field and writes a marked line
<IP> trinity.actual.battle.nettohosts. - After you agree, installs your root CA as trusted (it shows the SHA-256 and the name
Forever Private Root CA). - Starts
_classic_beta_\WowB.exeand swaps the server key in the process memory; game files on disk don't change.
Modes: Internet (address from the domain), Home network (local [local IP], for playing from your own network) and Auto. Administrator rights are requested separately, only to write to hosts and the certificate store.
Before agreeing to install the CA, compare the SHA-256 in the window with the real one on the server:
openssl x509 -in ~/forever/certs/ca/forever_root_ca.crt -noout -fingerprint -sha256The launcher only needs the public forever_root_ca.crt. The private keys forever_root_ca.key and forever.key.pem never leave the server. To get the file on the PC:
scp -i $env:USERPROFILE\.ssh\forever_vps root@[local IP]:/root/forever/certs/ca/forever_root_ca.crt $env:USERPROFILE\Downloads\forever_root_ca.crtThe Remove changes button removes the hosts entry and the installed certificate. Check after the rollback (the commands must print nothing):
Select-String -Path C:\Windows\System32\drivers\etc\hosts -Pattern "trinity.actual.battle.net"
Get-ChildItem Cert:\CurrentUser\Root, Cert:\LocalMachine\Root | Where-Object { $_.Subject -match "Forever" }If you regenerate the certificates or change PUBLIC_ADDRESS, the launcher has to be rebuilt with the new forever_root_ca.crt. The forever_docker repository also has a ready path: a fork on GitHub, the variables FOREVER_CA_CERT and FOREVER_SERVER_ADDRESS, and a build through Actions (the Publish the launcher section of its README).
Maintenance#
All commands run in ~/forever on the server.
| Task | Command |
|---|---|
| Status | docker compose ps |
| Live world and login logs | docker compose logs -f worldserver bnetserver |
| Stop everything | docker compose stop |
| Start again | docker compose up -d |
| Restart one service | docker compose restart worldserver |
| World console | docker compose attach worldserver (leave with Ctrl+P, Ctrl+Q) |
Update (per the repository README; make sure beforehand that the patches from step 6 still apply, otherwise the build stops on them):
cd ~/forever && docker compose build --no-cache && docker compose up -dDatabase backup. The MySQL root password comes from the container environment, so it isn't in the command:
cd ~/forever && docker compose exec -T mysql sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases' > ~/forever-backup-$(date +%F).sqlSeparately save the ~/forever/certs folder (certificates and keys) and the ~/forever/.env file. Without the CA keys you'd have to regenerate the certificates and rebuild the launcher.
Disk space. The Docker build cache can be cleared, but the next rebuild then starts from scratch:
docker builder prune -f && df -h /DBErrors.log can grow to hundreds of megabytes. To empty it:
cd ~/forever && docker compose exec worldserver sh -c ': > /opt/trinitycore/logs/DBErrors.log'Mmaps later. Copy the client to the server again, set EXTRACT_MMAPS=1 in .env, run docker compose run --rm extractor, then set MMAP_PATHFINDING=1 and restart the world.
Security#
The main risk here is a single one: a leaked CA private key lets someone issue certificates trusted by the PCs of everyone who installed your root certificate.
forever_root_ca.keyandforever.key.pemlive only on the server. Don't copy them to a PC or paste them into chats and repositories.- Find out whether the CA is limited by names. If there's no output, it isn't limited, and it's better not to hand such a CA out to friends.
openssl x509 -in ~/forever/certs/ca/forever_root_ca.crt -noout -ext nameConstraints~/forever/.envcontains the database and SOAP passwords. Don't send it to anyone.- Only 1119, 8081 and 8085 are open to the outside. Don't forward 3306, 7878 and 3443.
- Lines like
SSL Handshake failed http requestfrom unknown addresses in the bnetserver log mean scanners are probing the open ports. It's noise, but it confirms the ports are visible from the internet. - Keep SSH key-only on the server; for a VPS open to the internet, add
fail2ban. - When you're not playing, press Remove changes in the launcher: the extra root certificate is only needed on the PC while you play.
Blizzard's rules for the beta client and the possible consequences of swapping the key in memory for your main Battle.net account are yours to assess: this can't be checked in advance.
Troubleshooting#
Most problems from this install fit the table below: the first column is what you see, the second is the cause, the third is what to do.
| Symptom | Cause | Fix |
|---|---|---|
| SSH says Permission denied with the correct password | Root password login is forbidden by default | Log in with a key (step 2) |
Docker fails with an error about runc | Nesting isn't enabled in LXC | pct set <CTID> --features nesting=1,keyctl=1 and a restart (step 1) |
swapfile can't be created in the container | Swap in LXC is set by the host | pct set <CTID> --swap 8192 (step 1) |
The build fails: patch does not apply | The branch moved on and the patch no longer fits | Move this patch out of docker/patches/ (step 6) |
The build stops with no message, the log has Killed | Out of memory | Set BUILD_JOBS=2 in .env and repeat the build |
invalid IP address in add-host | A domain got into extra_hosts instead of an IP | Fix the line in docker-compose.yml (step 6) |
Could not resolve LoginREST.ExternalAddress in bnetserver | trinity.actual.battle.net doesn't resolve inside the container | Check extra_hosts in the compose file |
| Launcher: nothing in the bnetserver log | The client didn't reach the server | Check hosts, port 1119, the network mode (Internet or Home network) |
SSL Handshake failed from your address in the bnetserver log | The client doesn't trust the certificate | Check that the CA is installed, compare the fingerprint |
| Login works, then "You have been disconnected" | Failure after login | Watch the worldserver and bnetserver logs during the attempt, check realmlist and port 8085 |
| Can't connect from home by domain, works for friends | The router has no NAT loopback | Home network mode and the address [local IP] |
Check the realm address in the database:
cd ~/forever && docker compose exec -T mysql sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT id,name,address,localAddress,localSubnetMask,port FROM auth.realmlist"'Logs from the container's disk:
cd ~/forever && docker compose exec worldserver sh -c 'tail -n 40 /opt/trinitycore/logs/Bnet.log; echo ======; tail -n 60 /opt/trinitycore/logs/Server.log'Log files are in /opt/trinitycore/logs: Bnet.log, Server.log, DBErrors.log, GM.log.