Skip to content
Open console

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.

  • 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-tier if you used --name my-workspace.
  • ZCP_REGION and ZCP_PROJECT still exported from the previous tutorial, or re-export them.
  • The same SSH key name from that tutorial’s Step 4.
  • jq installed. The deploy and teardown scripts in this tutorial require it, same as the previous tutorial’s.

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-storage

This 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.

FlagPurposeDefault / requirement
--ssh-keyKey name, used for the storage VMRequired
--tier-nameThe existing private tier to attach toRequired
--namePrefix for the VM and volumestorage
--share-nameNFS share directory namecompany-share
--volume-sizeData volume size in GB20
--regionzcp region slugRequired (flag or env var)
--projectzcp project slugRequired (flag or env var)
-y/--yesSkip the confirmation promptOff

--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.

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.

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.

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).

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.

zcp instance list
zcp volume list
zcp ip list

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-common
sudo mkdir -p /mnt/company-share
sudo mount -t nfs <storage-vm-tier-ip>:/srv/nfs/company-share /mnt/company-share
echo "test" | sudo tee /mnt/company-share/test.txt
cat /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.

# from the public internet:
nc -zv -w 3 <storage-vm-public-ip> 2049

This 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.

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-delete

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.

  1. 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.
  2. 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.
  3. 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.
  4. Run destroy-private-storage.sh --name <prefix> --allow-unverified-volume-delete when you’re done, then check for a leftover network the same way the previous tutorial’s 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.