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.
Before you start
Section titled “Before you start”- 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-tierif you used--name my-workspace. This tutorial doesn’t depend on the storage tutorial. ZCP_REGIONandZCP_PROJECTstill exported from an earlier tutorial, or re-export them.- The same SSH key name from that tutorial’s Step 4.
git,jq,ssh, andcurlinstalled.gitdownloads the reviewed deployment tree. Unless you pass--my-ipexplicitly, the deploy script usescurlforifconfig.mepublic-IP detection. The teardown script further down only needsjq, 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.
Run the script
Section titled “Run the script”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-toolsgit -C employee-desktop-tools checkout --detach a0939bc0389380266cd4d071c6cc152bb596f68eReview 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.shThen 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-keyThis 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.
| Flag | Purpose | Default / requirement |
|---|---|---|
--name | Exact name for the desktop VM | Required |
--tier-name | The existing private tier to attach to | Required |
--username | The desktop login, provisioned via cloud-init | Required |
--ssh-key | Key name, used for the desktop VM | Required |
--password | The desktop login’s password | Auto-generated locally, printed before creation and again at the end |
--region | zcp region slug | Required (flag or env var) |
--project | zcp project slug | Required (flag or env var) |
--my-ip | Your public IP in CIDR form, scopes SSH access | Auto-detected via ifconfig.me, appended with /32 |
--vm-template | ubuntukde marketplace template slug | Auto-discovered, must be version 1.0.2 or later |
--vm-plan | Compute plan for the desktop VM | Auto-selects the smallest plan meeting a 4 vCPU/16GB baseline |
--network-plan | Network plan for the VM’s public IP | Auto-discovered |
--storage-category | Storage category for the VM’s root disk | Auto-discovered |
--billing-cycle | hourly or monthly | hourly |
--ssh-wait | Seconds to wait for SSH to come up | 180 |
--cloud-init-wait | Seconds to wait for desktop provisioning to complete | 1800 |
--adopt-existing | Proceed if a VM named --name already exists | Off |
-y/--yes | Skip the confirmation prompt | Off |
--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.
What the script builds
Section titled “What the script builds”Template selection and version check
Section titled “Template selection and version check”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 desktop VM
Section titled “The desktop VM”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.
The cloud-init desktop user
Section titled “The cloud-init desktop user”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-configwrite_files: - path: /etc/zmi/deploy.env permissions: '0600' owner: root:root content: | UBUNTUKDE_USERNAME=janedoe UBUNTUKDE_PASSWORD=<generated-or-provided-password>SSH lockdown
Section titled “SSH lockdown”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.
Tier network interface
Section titled “Tier network interface”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.
Confirming the desktop is ready
Section titled “Confirming the desktop is ready”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.
Inspect what was created
Section titled “Inspect what was created”zcp instance listzcp ip listGive 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 janedoesudo groupmod -g 2001 janedoesudo find /home/janedoe -exec chown -h 2001:2001 {} +id janedoePick a unique value per employee across your whole fleet.
Connect over RDP
Section titled “Connect over RDP”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
--usernameand password from the script’s summary
Verify the desktop works end to end
Section titled “Verify the desktop works end to end”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.
Verify isolation
Section titled “Verify isolation”# from the public internet:nc -zv -w 3 <desktop-public-ip> 3389This 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.
Clean up
Section titled “Clean up”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.shReview the downloaded script before you run it:
less destroy-employee-desktop.shThen run it:
bash destroy-employee-desktop.sh --name jane-doe-desktopIt 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.
- 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>. - 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.
- Optionally reassign the desktop login’s UID over SSH before the employee’s first login, if this desktop will also mount shared storage.
- 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.
- 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.
Next steps
Section titled “Next steps”- Connect Your Ubuntu Desktops to Private Storage: mount the NFS share from Deploy Private Shared Storage, before handing this desktop’s RDP credentials to the employee
- Build a Private Network with Headscale: the tier and mesh this tutorial builds on
- Deploy Private Shared Storage: an NFS share inside the same tier, for teams that want a shared area alongside individual desktops
- CLI reference: every command and flag
- Tutorials overview: the full list of available tutorials