Deploy Private Shared Storage on ZCP
This tutorial deploys an NFS file share on a VM inside the private tier from Build a Private Network with Headscale. The share is reachable from the tier and the mesh network that tutorial built, and never exposed publicly.
By the end you have:
- A VM inside your existing private tier, with a separate data disk for storage
- An NFS share exported to the tier and the mesh, never on the public side
- Confirmation that the share works from a mesh client and that its public IP has nothing but SSH reachable on it
Plan for about 20 minutes.
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. This tutorial needs the exact tier name that build created, e.g.
my-workspace-tierif you used--name my-workspace. ZCP_REGIONandZCP_PROJECTstill exported from the previous tutorial, or re-export them.- The same SSH key name from that tutorial’s Step 4.
jqinstalled. The deploy and teardown scripts in this tutorial require it, same as the previous tutorial’s.
Run the script
Section titled “Run the script”Deploying the storage VM, its data disk, and the NFS export is one script:
zcp/deploy-private-storage.sh from the zsoftly/tools
repository. It runs through the same phases explained in the next section, in order.
bash <(curl -fsSL https://raw.githubusercontent.com/zsoftly/tools/main/zcp/deploy-private-storage.sh) \ --ssh-key my-key --tier-name my-workspace-tier --name my-storageThis tutorial uses my-storage as the --name prefix and my-workspace-tier as the tier from the
previous tutorial’s my-workspace example. Your own values will differ based on the --name you
used there and the one you choose here.
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 |
|---|---|---|
--ssh-key | Key name, used for the storage VM | Required |
--tier-name | The existing private tier to attach to | Required |
--name | Prefix for the VM and volume | storage |
--share-name | NFS share directory name | company-share |
--volume-size | Data volume size in GB | 20 |
--region | zcp region slug | Required (flag or env var) |
--project | zcp project slug | Required (flag or env var) |
-y/--yes | Skip the confirmation prompt | Off |
--tier-name is not auto-discovered. 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.
The script auto-discovers the compute plan, network plan, and both storage categories (VM root disk
and data volume, which can differ) the same way build-private-network.sh does. It also
auto-detects your public IP (--my-ip), used to scope the VM’s SSH access, via ifconfig.me. Pass
any flag explicitly to pin a specific value instead. Run the script with --help for the full list.
What the script builds
Section titled “What the script builds”The storage VM
Section titled “The storage VM”The script deliberately allocates a public IP to the VM (my-storage, named from the --name
prefix you pass), then locks it down to nothing but SSH.
A VM with no public network footprint sounds like the most private option. But it creates a real
problem: there’s no way to reach it, not even for the one-time setup that brings the tier network
interface up. The script deploys normally instead. It allocates a public IP, then locks SSH down to
your own IP the same way build-private-network.sh locks down both of its VMs. It never opens
anything else on the public side. The VM ends up just as unreachable for NFS from the outside as a
no-public-IP VM would be. The public IP exists only for tightly scoped admin access.
The VM is attached to the tier you named with add-network, the same step
build-private-network.sh uses for the subnet router.
Tier NIC and data disk
Section titled “Tier NIC and data disk”The tier interface comes up the same way the subnet router’s does. The data disk is detected, not assumed.
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, identical to how
build-private-network.sh handles the subnet router’s tier NIC.
The data disk is the volume (my-storage-data) created alongside the VM. The script identifies it
as “the whole disk that isn’t the root disk”, rather than assuming a fixed device name. Device
naming can vary by platform, and a script that guesses wrong on this step risks formatting the wrong
disk. Formatting is idempotent: if the disk is already formatted (a rerun), the script skips mkfs
rather than reformatting and destroying data. The script creates the share directory after
mounting, not before. A directory created before the mount lands on the root disk. The moment the
data disk is mounted on top of it, that directory gets hidden.
NFS export
Section titled “NFS export”Exported to both the tier CIDR and the mesh CIDR: the server accepts either source address, even though only one of them is actually reachable with today’s default configuration.
The script installs nfs-kernel-server and exports the share directory to two ranges: the tier’s
own CIDR, and 100.64.0.0/10, Headscale’s mesh address range (the same constant
build-private-network.sh uses for its ACL rules).
OS firewall
Section titled “OS firewall”A second, independent layer for NFS, scoped the same way as the export. SSH stays on the zcp
layer alone.
The script also opens the storage VM’s own OS-level firewall (ufw) for NFS’s three ports (2049,
111, 20048), scoped to the same two CIDRs as the export itself. No zcp firewall or port-forward
rule for these ports exists on the VM’s public IP either. Both layers matter for NFS. Neither alone
is the whole story.
SSH is different: the script explicitly allows it unscoped (0.0.0.0/0) at the ufw level. ufw’s
default for everything else stays deny, once --force enable turns it on. The restriction to your
own IP happens entirely at the zcp firewall layer instead, the same lockdown
build-private-network.sh applies to its own VMs. Scoping ufw itself to your IP too would work
today, but would lock you out the moment your IP changes, since ufw has no equivalent of the
script’s own stale-rule cleanup.
Inspect what was created
Section titled “Inspect what was created”zcp instance listzcp volume listzcp ip listVerify from a mesh client
Section titled “Verify from a mesh client”Use any device already connected via Tailscale to the Headscale server from the previous tutorial, not something physically on the tier. This is the scenario that matters:
sudo apt-get install -y nfs-commonsudo mkdir -p /mnt/company-sharesudo mount -t nfs <storage-vm-tier-ip>:/srv/nfs/company-share /mnt/company-share
echo "test" | sudo tee /mnt/company-share/test.txtcat /mnt/company-share/test.txt<storage-vm-tier-ip> is printed in the script’s final summary. company-share is the default
--share-name. Use your own value if you passed something different.
Verify isolation
Section titled “Verify isolation”# from the public internet:nc -zv -w 3 <storage-vm-public-ip> 2049This fails (connection refused or timeout). The storage VM’s public IP has SSH open and nothing else. NFS is reachable only from inside the tier or over the mesh, never from the public internet.
Clean up
Section titled “Clean up”Hourly billing runs while these resources exist. The teardown script removes the storage VM and its
data volume for a given --name prefix. It does not touch the private tier or VPC from the previous
tutorial.
bash <(curl -fsSL https://raw.githubusercontent.com/zsoftly/tools/main/zcp/destroy-private-storage.sh) \ --name my-storage --allow-unverified-volume-deleteIt 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.
- Deploy a VM inside the tier from the previous tutorial, with a separate data disk:
deploy-private-storage.sh --ssh-key <name> --tier-name <tier-name> --name my-storage. - The script locks the VM’s SSH down to your own IP, brings up the tier interface, formats and mounts the data disk, and exports it over NFS to both the tier and mesh CIDRs, with a matching OS firewall.
- Mount the share from a device already connected to the mesh, verify read/write, then confirm the storage VM’s public IP has nothing but SSH reachable on it.
- Run
destroy-private-storage.sh --name <prefix> --allow-unverified-volume-deletewhen you’re done, then check for a leftover network the same way the previous tutorial’s teardown does.
Next steps
Section titled “Next steps”- Deploy Ubuntu Employee Desktops: a full desktop for one employee inside the same tier
- Connect Desktops to Storage: mount this share on that desktop
- Build a Private Network with Headscale: the tier and mesh this tutorial builds on
- CLI reference: every command and flag
- Tutorials overview: the full list of available tutorials