Y3llowDuck
Ludus Active Directory Home Lab

Building a Ludus Range

Ludus · Proxmox · AD Lab · ~8 min read

I am a firm believer in hands-on practice when learning something. This applies to AWS, pentesting, Microsoft Purview, you name it. The problem is that online platforms are becoming more and more expensive. VMware Workstation is a quick solution. But if you are serious about your career, it's better to spend $1k on a small server and set up Ludus.

Ludus runs on Proxmox, which is the equivalent of vSphere without all the licensing and cost drama. It is open source and runs on Debian. And it lets us deploy very complex setups or cyber ranges in a matter of minutes, including Windows.

Granted, you have to learn a few commands to make the magic happen. They are simple ones. Ludus is declarative, so you write the whole range in a YAML file, push it to the server, then deploy. That's it. Go grab a coffee or a Coke Zero and you come back to a full AD environment.

This is exactly what I did to set up a small environment for my new Purview lab. Purview is Microsoft's data governance and DLP platform, the thing that is supposed to catch data walking out the door. That topic deserves its own post and I will get to it eventually. What follows is how I built the range it runs on: range PURVIEW, domain purview.lab, VLAN 10, network 10.4.0.0/16. Client and server are 1.11.5, community license.

00 — the two APIs

Know which port you're talking to

Ludus exposes two endpoints and they are not interchangeable. Get this wrong and you'll waste an hour wondering why a command that "should work" doesn't.

Port API Scope Reachable from
8080User API + web UIRanges, deploys, configsAnywhere over WireGuard
8081Admin APIUser creation and deletionlocalhost on the Ludus host only

Only step 1 touches 8081. Everything after that is 8080, including admin commands that carry a --user flag.

01 — range owner

Create the user that owns the range

Every range belongs to exactly one Ludus user, and that user ID becomes the range_id that prefixes every VM name. It's an admin API call, so run it on the Ludus host over SSH.

ludus host — create the range owner
root@ludus:~# ludus users add --name purview --userid PURVIEW --url https://127.0.0.1:8081 +---------+------------------+-------+--------------------------------------------------+ | USERID | PROXMOX USERNAME | ADMIN | API KEY | +---------+------------------+-------+--------------------------------------------------+ | PURVIEW | purview | false | PURVIEW.Kt7wQzR4mVnLbXc9UsHaZyE3jFgTd6NkQrXiUMpq | +---------+------------------+-------+--------------------------------------------------+

That API key is your credential for everything that follows, in this post and in anything you build on the range afterwards. The one above is invented, so don't bother.

⚠ choose the user ID carefully
The user ID becomes range_id and gets baked into every VM name in Proxmox, so PURVIEW gave me PURVIEW-purview-dc01. Changing it later means deleting the user, and that destroys the range. Keep it short too, it counts against the 15 character Windows hostname limit. And treat that API key like the full credential it is.

Grab the WireGuard profile now so you can reach the range from your workstation later.

ludus host — wireguard profile
root@ludus:~# ludus user wireguard --user PURVIEW > purview-wireguard.conf root@ludus:~# head -4 purview-wireguard.conf [Interface] PrivateKey = qO8Zt1mDx4uKp7Rbn2VhLc6WsEyA3jFgTd5NkQrXiUM= Address = 198.51.100.14/32 MTU = 1420
02 — security context

Become the range owner

Export that user API key. In this mode no --user flag is needed, and it can't be used, because a non admin key is already scoped to its own range.

shell — load the key, confirm who you are
$ export LUDUS_API_KEY='PURVIEW.Kt7wQzR4mVnLbXc9UsHaZyE3jFgTd6NkQrXiUMpq' $ ludus range status +---------+---------------+-----------------+---------------+-------------------+-----------------+ | USER ID | RANGE NETWORK | LAST DEPLOYMENT | NUMBER OF VMS | DEPLOYMENT STATUS | TESTING ENABLED | +---------+---------------+-----------------+---------------+-------------------+-----------------+ | PURVIEW | 10.4.0.0/16 | NEVER | 0 | NEVER DEPLOYED | FALSE | +---------+---------------+-----------------+---------------+-------------------+-----------------+ # empty range, but the USER ID column reads PURVIEW. That's the check.
● tip
ludus apikey prompts for the key and stores it, so you don't have to export it in every new shell. The alternative is keeping an admin key loaded and targeting the range with --user PURVIEW on every command. Range operations stay on 8080 either way.
03 — templates

Build the templates first

A range clones from templates, so nothing deploys until the ones you reference exist. Check before you write a single line of YAML.

kali — what is already built
$ ludus templates list +------------------------------------+--------------+ | TEMPLATE | STATUS | +------------------------------------+--------------+ | debian-11-x64-server-template | ✅ BUILT | | debian-12-x64-server-template | ❌ NOT BUILT | | kali-x64-desktop-template | ❌ NOT BUILT | | win11-22h2-x64-enterprise-template | ✅ BUILT | | win2022-server-x64-template | ✅ BUILT | +------------------------------------+--------------+

This range needs three of those: win2022-server-x64-template for the DC, win11-22h2-x64-enterprise-template for the workstation, and debian-11-x64-server-template for the router Ludus builds on its own. All three have to read ✅ BUILT or the deploy dies on the first clone.

Anything sitting at NOT BUILT, build it and watch Packer work.

kali — build and follow
$ ludus templates build [INFO] Template building started - this will take a while. Building 1 template(s) at a time. $ ludus templates logs -f 2026/08/25 18:04:11 ui: ==> proxmox-iso.win2022: Retrieving ISO 2026/08/25 18:04:11 ui: ==> proxmox-iso.win2022: Creating VM 2026/08/25 18:09:47 ui: ==> proxmox-iso.win2022: Starting VM 2026/08/25 18:41:02 ui: ==> proxmox-iso.win2022: Waiting for WinRM to become available... # ctrl-c stops following the log, it does not stop the build
⚠ this is the slow part
A full template build runs up to a few hours depending on hardware and internet speed, because Packer installs each OS from scratch. --parallel N builds several at once, but you get no per template logs in that mode. Good news is you only pay this once per Ludus server. Every range after that clones from finished templates in minutes.
04 — the config

Define the range, and don't define the router

Here's the part I did not expect. You don't create the router. Ludus deploys one automatically for every range, derived from the VLANs your VMs use. It handles routing, DHCP, DNS forwarding, and the firewall between VLANs. Omit it and you get the defaults, which is exactly what produced PURVIEW-router-debian11-x64 at 10.4.10.254.

So the config is two machines: the domain controller and one Windows 11 workstation joined to it. A DC on its own is a domain with nothing in it, and nothing to practice against. The quoted 'EOF' stops the shell from touching the {{ range_id }} braces.

kali — purview-range.yml
$ cat > purview-range.yml <<'EOF' ludus: - vm_name: "{{ range_id }}-purview-dc01" hostname: purview-dc01 template: win2022-server-x64-template vlan: 10 ip_last_octet: 10 ram_gb: 4 cpus: 2 windows: sysprep: true domain: fqdn: purview.lab role: primary-dc - vm_name: "{{ range_id }}-purview-ws01" hostname: purview-ws01 template: win11-22h2-x64-enterprise-template vlan: 10 ip_last_octet: 20 ram_gb: 8 cpus: 4 windows: sysprep: true domain: fqdn: purview.lab role: member EOF

role: primary-dc is what promotes the first VM. Ludus installs AD DS, creates the forest for purview.lab, and provisions the default domain accounts. The VLAN you pick becomes the third octet of every address in the range, so VLAN 10 gives you 10.4.10.0/24 with the router sitting on .254.

role: member on the second VM is the entire domain join. Ludus points the machine at the DC for DNS, joins it to purview.lab, and reboots it. You never see the join dialog. Both machines sit on VLAN 10, so they talk to each other without a firewall rule.

Windows 11 wants 8 GB to feel like a machine and not a punishment. Drop it to 4 if the host is tight, but expect it to drag.

⚠ rules that break the deploy
hostname must be 15 characters or fewer, and keep it literal. Putting {{ range_id }} in a hostname inflates it past the limit and the domain join fails. vlan has to be between 2 and 255. ip_last_octet must be unique in the VLAN, and avoid 254 because the router owns it. Spaces only, a single literal tab kills the YAML parse and the error tells you nothing useful.
kali — check for tabs, silence is clean
$ grep -Pn "\t" purview-range.yml $ # no output, no tabs, move on
05 — deploy

Push it and deploy

kali — set, verify, deploy
$ ludus range config set -f purview-range.yml [INFO] Your range config has been successfully updated. # confirm the server accepted it before deploying $ ludus range config get | head -6 ludus: - vm_name: "{{ range_id }}-purview-dc01" hostname: purview-dc01 template: win2022-server-x64-template vlan: 10 ip_last_octet: 10 # nothing exists yet, so no --limit and no tags $ ludus range deploy [INFO] range deploy started $ ludus range logs -f PLAY [Pre run checks] ********************************************************** TASK [Acquire session ticket] ************************************************** changed: [localhost] TASK [Check for valid dynamic inventory] *************************************** ok: [localhost] => { "changed": false, "msg": "Dynamic inventory loaded!" } PLAY [Create the router] ******************************************************* TASK [ludus_proxmox_clone : Clone template debian-11-x64-server-template] ******* changed: [PURVIEW-router-debian11-x64] PLAY [Build the Windows VMs] *************************************************** TASK [ludus_win_domain : Promote to primary domain controller] ***************** changed: [PURVIEW-purview-dc01] TASK [ludus_win_domain : Join purview.lab] ************************************* changed: [PURVIEW-purview-ws01]

Order of operations: the router is cloned and configured first, the VLAN and firewall come up, then the DC is cloned, syspreped, promoted to primary domain controller for purview.lab, and rebooted. The workstation goes last, because it cannot join a domain that does not exist yet.

⚠ expect 30 to 60 minutes, and don't touch it
Promoting a domain controller takes several reboots and Ansible blocks on wait_for_connection between each one. Long silences in the log are normal. Silence is not a hang. Never start a second deploy while status reads DEPLOYING. If you need to stop, use ludus range abort.
06 — verify

Verify it actually built

kali — ludus range status
$ ludus range status +---------+---------------+------------------+---------------+-------------------+-----------------+ | USER ID | RANGE NETWORK | LAST DEPLOYMENT | NUMBER OF VMS | DEPLOYMENT STATUS | TESTING ENABLED | +---------+---------------+------------------+---------------+-------------------+-----------------+ | PURVIEW | 10.4.0.0/16 | 2026-08-25 19:12 | 3 | SUCCESS | FALSE | +---------+---------------+------------------+---------------+-------------------+-----------------+ +------------+-----------------------------+-------+-------------+ | PROXMOX ID | VM NAME | POWER | IP | +------------+-----------------------------+-------+-------------+ | 119 | PURVIEW-router-debian11-x64 | On | 10.4.10.254 | | 120 | PURVIEW-purview-dc01 | On | 10.4.10.10 | | 121 | PURVIEW-purview-ws01 | On | 10.4.10.20 | +------------+-----------------------------+-------+-------------+

Three VMs, router on .254, DC and workstation on their declared static IPs. Note again that the router was never in my config. Ludus created it.

Now, we are not done yet. The range lives on 10.4.10.0/24 behind the Ludus host, so nothing on your workstation can reach it until the tunnel is up. We need the WireGuard profile from section 01. Copy purview-wireguard.conf down from the Ludus host, Add Tunnel in the WireGuard client, Activate.

WireGuard client on Windows with the purview tunnel active, showing a recent handshake and traffic counters

Active does not mean much on its own. I look at Latest handshake. A few seconds old with the transfer counters moving and the tunnel is up. No handshake at all and the profile is dead, green icon or not.

Each range user gets its own profile, so I keep kali, ludus and purview separate in that list. Different credentials, not copies.

You can check the same thing from the Ludus host if the client side looks wrong and you want to know which end is at fault.

ludus host — peer handshakes
root@ludus:~# wg show interface: wg0 public key: Xk2pQd7VnR4mLbTc9UwHsZaY3eFgJt6NkQrXiUM= private key: (hidden) listening port: 51820 peer: 9fRt2KmVx7NpQc4LbZa6HsWyE3jUgTd5NkQrXiUM= endpoint: 203.0.113.42:54924 allowed ips: 198.51.100.14/32 latest handshake: 41 seconds ago transfer: 1.30 MiB received, 7.48 MiB sent persistent keepalive: every 25 seconds

Sent and received are backwards from what the client shows, which is right. What the client sent is what the server received. A peer with no handshake line never came up at all.

● default credentials
These apply when the config has no defaults block, which is the case here. purview\domainadmin, purview\domainuser, and the local localuser account all use password. Autologon switches to the domain user once a machine is joined.
workstation — tunnel up, port open
PS C:\Users\jose> Test-NetConnection 10.4.10.10 -Port 3389 ComputerName : 10.4.10.10 RemoteAddress : 10.4.10.10 RemotePort : 3389 InterfaceAlias : purview SourceAddress : 198.51.100.14 TcpTestSucceeded : True

If that comes back False, stop. The problem is the tunnel or the Ludus host, not the range, and no amount of poking at the DC will fix it.

With the tunnel up there is no RDP file to download and nothing to configure beyond the IP. I open MobaXterm, new RDP session, 10.4.10.10, purview\domainadmin and the default password. MobaXterm keeps every session in one tabbed window instead of scattering RDP clients all over the desktop, which matters more the moment the range is bigger than two machines.

Once you are on the DC, open Active Directory Users and Computers. That is the check. purview.lab with its OU tree under it means the promotion worked, and PURVIEW-WS01 sitting in the Workstations OU means the join worked.

Active Directory Users and Computers on the DC showing PURVIEW-WS01 inside the Workstations organizational unit of purview.lab

No domain in the console means the promotion failed and everything you build on this range fails with it. Domain there but no computer object means the workstation cloned and never joined. Check DNS on the workstation first, that is what it usually is.

Last thing. Snapshot the clean range before you build anything on top of it. Restoring costs seconds, redeploying costs an hour.

kali — snapshot the baseline
$ ludus snapshots create baseline -d "clean dc + joined ws01" [INFO] Snapshot baseline created for 3 VM(s) $ ludus snapshots list VM 120 (PURVIEW-purview-dc01) └── baseline 2026-08-25 19:26:04 (clean dc + joined ws01) [includes RAM] └── current (You are here!) VM 121 (PURVIEW-purview-ws01) └── baseline 2026-08-25 19:26:11 (clean dc + joined ws01) [includes RAM] └── current (You are here!)

Rolling back later is ludus snapshots revert baseline. Add -n 121 to put only the workstation back and leave the DC where it is.

07 — grow the range

Adding the next machine

Every VM after these two is the same three steps: append it to the YAML, push the file, deploy. The difference is that now there is something running you don't want rebuilt, so scope the deploy instead of firing the whole range again.

kali — deploy one VM
$ ludus range config set -f purview-range.yml [INFO] Your range config has been successfully updated. $ ludus range deploy --limit PURVIEW-purview-ws02 [INFO] range deploy started

The name after --limit is the rendered vm_name, range_id and all, not the hostname. Get it wrong and Ansible matches nothing, the play ends in seconds, and the log reads like a success.

⚠ config set replaces, it does not merge
The file you push is the whole range, not a patch. Keep every machine in it. Push a file with only the new box and you have just told Ludus that one box is your entire range.
08 — cheat sheet

Commands worth keeping

quick reference
# admin API, on the Ludus host only, port 8081 ludus users add --name <name> --userid <ID> --url https://127.0.0.1:8081 # user API, port 8080 ludus range status # deployment state and live IPs ludus range logs -f # follow the running deploy ludus range errors # failures from the last run ludus range deploy --limit <vm_name> # deploy or redeploy one VM ludus range abort # stop a running deploy ludus range gettags # list valid deploy tags ludus templates list # available templates and build state ludus templates build # build all templates, takes hours ludus user wireguard # VPN profile for reaching the range

That's a working range: an automatic router, a domain controller running purview.lab, a workstation joined to it, and a snapshot to roll back to. Everything after this is the same three steps, so the range grows as fast as you can write YAML.

● one thing I skipped
None of this runs until Ludus itself is installed. The official docs cover it well and I am not going to repeat them here. Mine runs on a GMKtec EVO-X1 with a Ryzen AI 9 HX-370. Around a thousand dollars, quiet enough to sit on a desk, and it carries a range like this without complaining, with room for many more.