Skip to content
Open console

Deploy Ubuntu Employee Desktops on ZCP

This tutorial deploys a full Ubuntu KDE desktop for one employee on a VM inside the private tier from Build a Private Network with Headscale. The desktop is reached only through the mesh over RDP, and RDP is never exposed publicly. Once connected, it behaves like any other remote desktop.

By the end you have:

  • A VM inside your existing private tier, running a full Ubuntu KDE desktop
  • A named login for the employee, provisioned via cloud-init, not the template’s own default account
  • Confirmation that the desktop works end to end over RDP, and that its public IP has nothing but SSH reachable on it

Plan for about 30 minutes: image deploy, first-boot KDE provisioning, and bringing up the tier network interface.

  • Build a Private Network with Headscale complete: a VPC with a private tier, a Headscale server, and a subnet router already advertising and serving that tier’s route, e.g. my-workspace-tier if you used --name my-workspace. This tutorial doesn’t depend on the storage tutorial.
  • ZCP_REGION and ZCP_PROJECT still exported from an earlier tutorial, or re-export them.
  • The same SSH key name from that tutorial’s Step 4.
  • git, jq, ssh, and curl installed. git downloads the reviewed deployment tree. Unless you pass --my-ip explicitly, the deploy script uses curl for ifconfig.me public-IP detection. The teardown script further down only needs jq, same as the earlier tutorials.
  • An RDP client: the built-in Remote Desktop Connection on Windows, Windows App (formerly Microsoft Remote Desktop) from the macOS App Store, or Remmina or FreeRDP on Linux, running on a device already connected to the mesh from the previous tutorial. The desktop’s tier IP is reachable only from inside the tier or over the mesh.

Deploying the desktop VM, its cloud-init login, and the tier network interface is one script: zcp/deploy-employee-desktop.sh from the zsoftly/tools repository. It runs through the phases explained in the next section.

Download the deployment tree at the reviewed commit:

git clone --filter=blob:none --no-checkout https://github.com/zsoftly/tools.git employee-desktop-tools
git -C employee-desktop-tools checkout --detach a0939bc0389380266cd4d071c6cc152bb596f68e

Review the downloaded script and its helper files before you run it:

less employee-desktop-tools/zcp/deploy-employee-desktop.sh \
employee-desktop-tools/zcp/lib/deploy-employee-desktop/01-validate.sh \
employee-desktop-tools/zcp/lib/deploy-employee-desktop/02-resolve.sh \
employee-desktop-tools/zcp/lib/deploy-employee-desktop/03-create.sh \
employee-desktop-tools/zcp/lib/deploy-employee-desktop/04-finish.sh

Then run it:

bash employee-desktop-tools/zcp/deploy-employee-desktop.sh \
--name jane-doe-desktop --tier-name my-workspace-tier --username janedoe --ssh-key my-key

This tutorial uses jane-doe-desktop as --name, janedoe as --username, and my-workspace-tier as the tier from the previous tutorial’s my-workspace example. Your own values will differ.

Leave --password out and the script generates a strong random password locally and prints it before creating the VM, and again in the final summary. Save it then. It isn’t shown again on a rerun. Pass --password explicitly only if you need a specific value.

The script picks up ZCP_REGION and ZCP_PROJECT from your shell if you exported them. Pass --region/--project instead if you didn’t.

FlagPurposeDefault / requirement
--nameExact name for the desktop VMRequired
--tier-nameThe existing private tier to attach toRequired
--usernameThe desktop login, provisioned via cloud-initRequired
--ssh-keyKey name, used for the desktop VMRequired
--passwordThe desktop login’s passwordAuto-generated locally, printed before creation and again at the end
--regionzcp region slugRequired (flag or env var)
--projectzcp project slugRequired (flag or env var)
--my-ipYour public IP in CIDR form, scopes SSH accessAuto-detected via ifconfig.me, appended with /32
--vm-templateubuntukde marketplace template slugAuto-discovered, must be version 1.0.2 or later
--vm-planCompute plan for the desktop VMAuto-selects the smallest plan meeting a 4 vCPU/16GB baseline
--network-planNetwork plan for the VM’s public IPAuto-discovered
--storage-categoryStorage category for the VM’s root diskAuto-discovered
--billing-cyclehourly or monthlyhourly
--ssh-waitSeconds to wait for SSH to come up180
--cloud-init-waitSeconds to wait for desktop provisioning to complete1800
--adopt-existingProceed if a VM named --name already existsOff
-y/--yesSkip the confirmation promptOff

--tier-name is not auto-discovered, the same as the earlier tutorials’ scripts. An account can hold more than one private tier from earlier testing, and guessing which one to attach to is a real isolation risk. The script hard-errors if the name you pass doesn’t resolve to exactly one tier.

If a VM named --name already exists, the script stops and shows its slug rather than assuming it’s the one from an earlier run of this same script. A name match alone doesn’t prove that: it could just as easily be someone else’s unrelated VM. Check the slug against zcp instance list first. If it’s genuinely the right VM, re-run with --adopt-existing to let the script attach the tier, lock down SSH, and continue against it. Cloud-init doesn’t re-run on an adopted VM, so --password is ignored (its real password stays whatever was set at first boot) and --username must match the login that VM already has, not a new one you want created.

--username must match ^[a-z][a-z0-9_]*$ (lowercase letters, digits, and underscores only, starting with a letter), max 32 characters. The script checks it before creating anything. It also rejects a fixed list of reserved system account names outright, ubuntu among them. The rest of the list (xrdp, sddm, sshd, polkitd, and several others) is every account confirmed present on the desktop image itself, not just a guess: verified by deploying a real ubuntukde VM and reading its own account list directly. Picking any of them as the desktop login guarantees a collision. Run the script with --help to see every flag name. This tutorial covers what each one does.

The script finds the ubuntukde template for you, and refuses to deploy anything older than the version validated for this tutorial.

Without --vm-template, the script auto-discovers the ubuntukde template. zcp template list | grep -i ubuntukde shows the idea, but the script itself runs zcp template list -o json | jq -r '.[] | select(.name | test("ubuntukde";"i")) | .slug' | head -1 to pick the first match programmatically. It then parses the app version out of the template’s name (the .version field in the API is the OS version, e.g. 24.04 LTS, not the app version) and requires at least 1.0.2.

The script deliberately allocates a public IP to the VM, then locks it down to nothing but SSH, scoped to your own address. RDP is never opened on the public side at all.

A VM with no public network footprint sounds like the most private option. But this platform has no console or recovery access. If the one-time tier-interface setup below goes wrong, an unreachable VM stays unreachable. The script deploys normally instead. It allocates a public IP, then locks SSH down to your own IP. RDP is never opened on the public side at any point, so the desktop ends up just as unreachable over RDP publicly as a no-public-IP VM would be. The public IP exists only for tightly scoped admin access.

The script attaches the VM to the tier you named with add-network, the same step the earlier tutorials’ scripts use.

Each employee gets a named login provisioned via cloud-init, not the template’s own generated default user. The script validates the username and password before creating anything.

The script writes a small cloud-config to a local temp file (kept out of the VM-create command’s process arguments, restricted to owner-only, and removed when the script exits) and passes it with --user-data-file:

#cloud-config
write_files:
- path: /etc/zmi/deploy.env
permissions: '0600'
owner: root:root
content: |
UBUNTUKDE_USERNAME=janedoe
UBUNTUKDE_PASSWORD=<generated-or-provided-password>

The script automatically finds and removes the template’s own default SSH firewall rule, which is open to any address. Only your own IP keeps access.

Marketplace App templates like this one get a default SSH firewall rule at deploy time, open to any address (0.0.0.0/0, both TCP and UDP port 22), not something you created and not scoped to you. The script creates a rule scoped to your own IP first, confirms it exists, then removes the open rule and confirms nothing on 0.0.0.0/0 remains on port 22, the same lockdown build-private-network.sh applies to its own VMs.

On a rerun, the script also removes any port-22 rule scoped to a different IP than the current run’s, with a printed [WARN]. If your public IP changed since the last run, the script revokes that old IP’s SSH access in favor of the new one.

The tier interface comes up the same way it does for the subnet router and storage VM in the earlier tutorials.

The platform hot-adds the tier’s network interface, but the operating system doesn’t bring it up automatically. The script writes a netplan file for it and applies it, then confirms the address that came up falls inside the tier’s CIDR before declaring success.

The script polls for the cloud-init login to exist, rather than assuming first-boot provisioning finished the moment SSH answered.

The script first polls for SSH itself to come up, for up to 3 minutes by default (--ssh-wait), before trying anything else. First-boot KDE provisioning (installing and configuring the desktop, xrdp, and creating the employee’s login) often continues for several minutes after SSH becomes reachable. A UID alone isn’t a reliable enough signal that provisioning finished. The template’s first-boot script creates the user before it sets the password and starts xrdp. So the script also waits for the first-boot script’s own completion marker and for xrdp to be active, in addition to the username existing with a human UID (1000 or higher). All three are required, for up to 30 minutes by default (--cloud-init-wait), rather than erroring on a deploy that’s still finishing.

If either wait times out, the script errors instead of hanging indefinitely. The VM already exists at that point, so re-running needs --adopt-existing; check zcp instance get <slug> first to confirm it’s genuinely the right VM before adding that flag. Raise the relevant timeout too if needed. For the cloud-init wait specifically, the error also points you at sudo journalctl -u ubuntukde-first-boot on the VM. That’s the unit that creates the user, sets the password, and starts xrdp. It runs after cloud-init’s own cloud-final finishes, not as part of it. So checking cloud-final alone won’t show what went wrong here.

zcp instance list
zcp ip list

Give this desktop a unique identity on shared storage

Section titled “Give this desktop a unique identity on shared storage”

Only relevant if you also plan to mount private shared storage on this desktop, and cheapest to do before the employee’s first login: it’s a manual, SSH-based step, not something the deploy script automates. NFS does raw UID-number mapping, not username mapping, and useradd assigns sequential UIDs starting at 1000 - since each desktop VM only ever creates one custom user, every employee’s desktop login gets the same UID by default (typically 1001), making them the same filesystem identity on shared storage regardless of username.

To assign a unique identity instead, usermod and groupmod reassign the UID and GID before first login. The desktop’s public IP is in the script’s final summary (or zcp ip list):

ssh ubuntu@<desktop-public-ip>
sudo usermod -u 2001 janedoe
sudo groupmod -g 2001 janedoe
sudo find /home/janedoe -exec chown -h 2001:2001 {} +
id janedoe

Pick a unique value per employee across your whole fleet.

Run the RDP client from a device already connected to the mesh from the previous tutorial. The desktop’s tier IP is reachable only from inside the tier or over the mesh, so a client on any other network can’t reach it at all.

Connect to the desktop’s tier IP, printed in the script’s final summary, never the public IP. RDP was never opened on the public side at all, only SSH, and that’s locked to your own IP.

  • Address: the tier IP from the script’s summary (for example 10.20.1.57)
  • Username and password: the --username and password from the script’s summary

Launch Firefox or Chromium from the KDE application launcher. This is why the script’s version check rejects anything older than 1.0.2 before deployment ever starts: on those images, the app flashes and closes immediately with no window, and that upfront check means you won’t hit the bug here. Open a terminal too. It confirms the desktop is a usable work environment, not a browser demo. Confirm internet access works from inside the session.

# from the public internet:
nc -zv -w 3 <desktop-public-ip> 3389

This fails (connection refused or timeout). The desktop’s public IP (from the script’s summary, or zcp ip list) has SSH open and nothing else. RDP is reachable only from inside the tier or over the mesh, never from the public internet, since it’s never opened on the public side at all.

Optionally, confirm the same thing from the firewall’s own side:

zcp firewall list --ip <ip-slug>

<ip-slug> is printed by zcp ip list. The only firewall rule you should see on the desktop’s public IP is the scoped SSH rule from the lockdown above, not an open one.

zcp portforward list --ip <ip-slug> still shows the template’s own tcp and udp port-22 forwards, Active, even after the lockdown. That’s expected. A port-forward rule with no matching firewall rule routes nothing, so the firewall above is the actual gate. There’s nothing to change here.

Hourly billing runs while this VM exists. Unlike the storage tutorial, there’s no companion volume to clean up here: this desktop VM has none.

Download the teardown script at the reviewed commit:

curl --fail --show-error --location --output destroy-employee-desktop.sh \
https://raw.githubusercontent.com/zsoftly/tools/a0939bc0389380266cd4d071c6cc152bb596f68e/zcp/destroy-employee-desktop.sh

Review the downloaded script before you run it:

less destroy-employee-desktop.sh

Then run it:

bash destroy-employee-desktop.sh --name jane-doe-desktop

It picks up ZCP_REGION/ZCP_PROJECT from your shell the same way the deploy script does. Pass --region/--project explicitly if you didn’t export them. The script hard-errors without one or the other, and has no confirmation prompt of its own, matching the teardown scripts in the earlier tutorials.

  1. Deploy a desktop VM inside the tier from the previous tutorial, with a named cloud-init login: deploy-employee-desktop.sh --name jane-doe-desktop --tier-name <tier-name> --username janedoe --ssh-key <name>.
  2. The script picks the ubuntukde template (enforcing version 1.0.2 or later), locks the VM’s SSH down to your own IP, and brings up the tier interface.
  3. Optionally reassign the desktop login’s UID over SSH before the employee’s first login, if this desktop will also mount shared storage.
  4. Connect over RDP, from a device already on the mesh, to the tier IP from the script’s summary, never the public IP. Confirm Firefox or Chromium and a terminal both work, then confirm the desktop’s public IP has nothing but SSH reachable on it.
  5. Run destroy-employee-desktop.sh --name <name> when you’re done, then check for a leftover network the same way the earlier tutorials’ teardown does.

Ask the ZCP docs

Type a question and pick an assistant. The assistant opens in a new tab and reads this page and the full ZCP documentation, so its answer comes from our docs.

Example questions

Ask with

Your question goes to the assistant you pick, under its own terms.