A customer’s virtual machines do not all need public IP addresses for administrators to reach them. One Ubuntu VM can provide the VPN entry point: connect a laptop to WireGuard, then use SSH, RDP or another service directly against each VM’s private IP.
This guide follows that process from creating the private network to logging in to a separate Ubuntu VM over the VPN. It finishes with the complete Python script that automates the gateway configuration, followed by its prompts and expected output. Everything needed to save and run the script is included here.
The implementation was tested with Ubuntu Server 24.04 and a macOS WireGuard client. A successful SSH login to a second private Ubuntu VM confirmed the full path. Windows RDP is included as the equivalent service configuration, but was not part of that live test.
Summary
Build one Ubuntu VPN gateway on a customer’s private cloud LAN, connect a laptop through WireGuard, then reach private VMs directly by IP. This guide covers network creation, gateway preparation, DNS, public access, laptop setup and a real SSH test. The complete automation script and its prompts/output are included at the end; no companion document is required.
Table of contents
- What we are building
- 1. Create the private LAN and router
- 2. Create the Ubuntu VPN gateway
- 3. Give the gateway a public VPN endpoint
- 4. Understand the gateway configuration
- 5. Prepare the laptop and run the installer
- 6. Update the laptop tunnel
- 7. Create a private VM and SSH into it
- 8. Use the same VPN for Windows RDP and other services
- When a connection fails
- Before using it for a customer
- Complete automated gateway script
- Running the script: prompts and expected output
- Adding and removing devices after installation
What we are building
The laptop connects to the Ubuntu gateway’s public endpoint. The gateway forwards traffic into the customer LAN, where Linux and Windows VMs keep their private addresses.
There are two separate networks:
| Component | Example address or name |
|---|---|
| Customer private network | CUSTOMER-LAN, 10.50.10.0/24 |
| SDDC router’s LAN address | 10.50.10.1 |
| Ubuntu VPN gateway’s LAN address | 10.50.10.10 |
| VPN gateway’s public endpoint | vpn.example.com:51820 |
| WireGuard tunnel network | 10.200.50.0/24 |
| Gateway’s tunnel address | 10.200.50.1 |
| First laptop’s tunnel address | 10.200.50.2 |
| Private Linux VM | 10.50.10.20 |
| Private Windows VM | 10.50.10.30 |
Replace these examples with your own addresses. vpn.example.com is a placeholder for a real hostname or public IP. Choose LAN and VPN ranges that do not overlap with each other or with networks already used by the laptop.
Only one private LAN needs to be created in the cloud platform. The WireGuard tunnel network is configured inside WireGuard; it does not need another SDDC virtual network.
This design grants access to the configured customer LAN. VMs on other subnets require additional routing and policy changes. The gateway does not act as an Internet exit for the laptop.
1. Create the private LAN and router
In the cloud or SDDC portal, create a private virtual network named CUSTOMER-LAN with these settings:
| Setting | Example value |
|---|---|
| IPv4 subnet | 10.50.10.0/24 |
| Gateway | 10.50.10.1 |
| DHCP | Enabled |
| DNS | Your provider’s or customer’s DNS servers |
Supply DNS through DHCP where possible so new VMs receive working resolver settings. If the customer uses Active Directory, use their internal DNS servers. Otherwise, permitted public resolvers such as 1.1.1.1 and 8.8.8.8 can be used.
Next, create a virtual router. Select the provider’s external network as its external gateway, enable outbound SNAT if the platform requires it, and attach CUSTOMER-LAN as an internal interface. That interface should use 10.50.10.1.
The virtual router provides the LAN’s normal external connectivity. The Ubuntu VPN VM will have its own address, 10.50.10.10. These are different devices and must not share an IP.
Keep 10.50.10.1 as the normal default gateway on workload VMs. The VPN design below does not require changing their default gateway to the Ubuntu VM.
2. Create the Ubuntu VPN gateway
Create a dedicated VM with the following starting configuration:
| Setting | Value |
|---|---|
| Name | VPN-GW01 |
| OS | Ubuntu Server 24.04 LTS |
| CPU | 2 vCPUs |
| RAM | 2–4 GB |
| Disk | 20–30 GB |
| Network | CUSTOMER-LAN |
| Private IP | 10.50.10.10, reserved or fixed on the VM’s cloud port |
Enable OpenSSH during installation, or use the cloud console to install it. Keep console access available while configuring networking. Use a fresh gateway: the automation stops if it detects active UFW, firewalld, Docker or certain existing firewall rules that need manual integration.
On the gateway, check its address, route, DNS and Python:
ip -4 -brief address ip -4 route resolvectl status python3 --version The LAN interface should have the reserved private IP and a default route through 10.50.10.1. The interface name in this example is enp1s0; use the actual name shown on your VM.
If DNS is missing
During the build, package downloads failed with Temporary failure resolving archive.ubuntu.com. The VM could reach an external IP, but had no working DNS servers.
For an immediate repair on the gateway:
sudo resolvectl dns enp1s0 1.1.1.1 8.8.8.8 sudo resolvectl domain enp1s0 '~.' sudo resolvectl default-route enp1s0 yes resolvectl query archive.ubuntu.com These settings are temporary. For a VM whose existing Netplan entry is enp1s0 with dhcp4: true, make DNS persistent using an overlay:
sudo netplan get sudo nano /etc/netplan/99-vpn-dns.yaml Add:
network: version: 2 ethernets: enp1s0: dhcp4: true dhcp4-overrides: use-dns: false nameservers: addresses: - 1.1.1.1 - 8.8.8.8 If the interface name or existing network configuration differs, update the matching entry instead. Keep the existing addressing and routes.
sudo chmod 600 /etc/netplan/99-vpn-dns.yaml sudo netplan generate sudo netplan try --timeout 120 Press Enter to accept while connectivity is working. Keep the console open and verify any rollback if the trial fails. Editing /etc/resolv.conf directly may restore DNS temporarily, but the system can overwrite it later.
Verify package access:
resolvectl query archive.ubuntu.com sudo apt update Python 3 is normally already present on Ubuntu Server. If it is missing:
sudo apt install -y python3 3. Give the gateway a public VPN endpoint
Assign a floating public IP to the Ubuntu gateway’s private address, 10.50.10.10. If the platform uses an edge firewall instead, forward UDP 51820 from a public address to 10.50.10.10:51820.
If using a hostname, create its DNS record pointing to that public address. Leave the guest NIC configured with its private IP; the cloud platform normally handles the floating-IP translation.
Apply the following security-group or edge-firewall rules:
| Location | Rule |
|---|---|
| VPN gateway inbound | Allow UDP 51820 from the intended clients’ public IPs, or any source for roaming clients |
| VPN gateway management | Allow TCP 22 only from approved administration sources |
| VPN gateway outbound | Allow required DNS, package downloads and traffic to the customer LAN |
| Linux workload inbound | Allow TCP 22 from 10.50.10.10/32 |
| Windows workload inbound | Allow TCP 3389 from 10.50.10.10/32; UDP 3389 is optional |
Check all groups attached to the VM’s actual interface. In platforms with cumulative allow rules, an unrestricted default group can undermine a restrictive additional group.
The automation configures the Ubuntu guest. The public-IP mapping and cloud security rules remain portal-side tasks.
4. Understand the gateway configuration
The script at the end of this article performs the gateway setup. Read this section to understand what it creates; you do not need to create these configuration files manually before running it.
It installs WireGuard and nftables, generates server keys, assigns 10.200.50.1/24 to wg0, registers the laptop’s public key and enables IPv4 forwarding.
A first device receives 10.200.50.2/32. On the server, its peer configuration permits that device to use only its assigned tunnel address. The laptop configuration routes the customer LAN through the gateway.
The script also configures source NAT. An SSH packet leaves the laptop with source 10.200.50.2 and destination 10.50.10.20:22. Before entering the LAN, its source becomes the gateway’s address, 10.50.10.10. The target replies to the gateway, which translates and encrypts the reply back to the laptop.
That is why workload firewall rules use the gateway’s LAN IP as their source. Individual workload login accounts still identify the user, but target network logs see the NAT address.
Forwarding is allowed for IPv4 services across the configured LAN, not only RDP. Traffic outside that LAN and forwarding between VPN clients are blocked. The script enables systemd startup for WireGuard and its firewall rules.
5. Prepare the laptop and run the installer
Install the official WireGuard client on the laptop. In the app, create an empty tunnel named CustomerVPN-Client. Copy its public key and keep its private key on the laptop.
This is the WireGuard public key, not the SSH public key used to log into a Linux VM. Do not generate another laptop key after registering it unless you also update the server.
On the Ubuntu gateway, create the script file:
nano ~/vpn-gateway.py Paste the complete Python code at the end of this article, save the file, then run:
sudo python3 ~/vpn-gateway.py install The installer asks for the public endpoint, LAN CIDR, tunnel CIDR, a device name and the laptop public key. A complete prompt-and-output example appears immediately after the code.
No pip packages are required. The script uses Python’s standard library and installs its OS packages with APT. Once it finishes, it prints the laptop settings, including the new server public key.
6. Update the laptop tunnel
In the laptop’s WireGuard app, edit CustomerVPN-Client. Keep its existing private key and use the address and peer settings printed by the installer:
[Interface] PrivateKey = YOUR_EXISTING_LAPTOP_PRIVATE_KEY Address = 10.200.50.2/32 [Peer] PublicKey = SERVER_PUBLIC_KEY_PRINTED_BY_INSTALLER Endpoint = vpn.example.com:51820 AllowedIPs = 10.200.50.1/32, 10.50.10.0/24 PersistentKeepalive = 25 Replace the placeholders with actual values. Do not paste a placeholder over your generated private key. If reusing a tunnel from an older gateway, update its peer public key, endpoint and AllowedIPs.
Save and activate the tunnel. The laptop now routes traffic to 10.50.10.0/24 through WireGuard, while ordinary Internet traffic continues using its normal connection.
On macOS, test the gateway tunnel address:
ping -c 4 10.200.50.1 On the Ubuntu gateway, check:
sudo wg show wg0 Look for a recent handshake and traffic in both directions. An Active badge alone does not prove the remote connection works. In the completed test, all four tunnel pings returned successfully with zero loss.
7. Create a private VM and SSH into it
Create a second Ubuntu VM on the same CUSTOMER-LAN. For this example, its address is 10.50.10.20. It does not need a floating public IP, a WireGuard installation or another laptop VPN connection.
Use its console to confirm the address and enable SSH:
# Run on the new private Ubuntu VM hostname -I sudo apt update sudo apt install -y openssh-server sudo systemctl enable --now ssh sudo ss -lntp | grep ':22' The cloud security group must allow TCP 22 from 10.50.10.10/32. If UFW is already active on this workload VM, add the corresponding guest rule:
sudo ufw allow from 10.50.10.10 to any port 22 proto tcp From the Mac with WireGuard active, connect directly:
ssh appadmin@10.50.10.20 Use the account created on that VM. Verify its host-key fingerprint through the console before accepting a first connection. You can display the target fingerprint on its console with:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub Our first attempt returned Connection refused. Installing/enabling the SSH server resolved it; the next attempt reached the host-key prompt, accepted authentication and opened the separate VM’s Ubuntu shell.
That login confirmed the complete path: laptop → WireGuard gateway → private workload VM.
New VMs on this subnet are covered by the existing 10.50.10.0/24 client route. They still need their own services, credentials and firewall rules configured.
8. Use the same VPN for Windows RDP and other services
For a Windows VM at 10.50.10.30, enable Remote Desktop, keep Network Level Authentication enabled and grant the intended account permission to sign in remotely.
Allow TCP 3389 from the VPN gateway in the Windows guest firewall and cloud security group. Example in Administrator PowerShell on the Windows workload:
New-NetFirewallRule ` -DisplayName "RDP from VPN gateway" ` -Direction Inbound ` -Action Allow ` -Protocol TCP ` -LocalPort 3389 ` -RemoteAddress 10.50.10.10 ` -Profile Any Review existing broader RDP rules as well. With the laptop VPN active, open your RDP client and connect to 10.50.10.30. A public RDP port is not needed.
Web applications and other services follow the same pattern: connect to the private IP, with the service listening and its firewall permitting the gateway’s source. The gateway is not restricted to SSH or RDP.
When a connection fails
| Symptom | Check |
|---|---|
| No handshake | Public endpoint, floating-IP association, UDP 51820 rules and reciprocal public keys |
| Handshake works but tunnel ping fails | Laptop tunnel address, client routes and gateway firewall |
| Tunnel ping works but workload times out | Workload LAN attachment, forwarding/NAT, cloud security group and guest firewall |
SSH says Connection refused | SSH server installation, service/socket and listener on the workload |
SSH says Permission denied | Workload username, password/SSH key and login policy |
| Private IP works but hostname does not | Internal DNS server reachability and laptop DNS configuration |
For a Linux target, these are the first checks to run in its console:
sudo systemctl status ssh --no-pager sudo ss -lntp | grep ':22' sudo ufw status On the gateway:
sudo customer-vpn status sudo nft list table inet customer_vpn sudo journalctl -u wg-quick@wg0 -u customer-vpn-firewall -n 50 --no-pager Before using it for a customer
The successful test demonstrated the tunnel and SSH access to a separate Ubuntu VM. Test Windows RDP if required, device revocation and access after a planned gateway reboot before handover.
The script enables startup automatically. You can inspect that configuration with:
sudo systemctl is-enabled customer-vpn-firewall.service wg-quick@wg0.service Keep the gateway IP reserved and back up its secret configuration through your approved process. /etc/customer-vpn/state.json and /etc/wireguard/wg0.conf contain the server private key and must not be published.
This implementation supports one customer IPv4 LAN and a /24 VPN pool. All enrolled devices share that LAN access. It does not configure MFA, high availability or per-user destination restrictions. Its host firewall preserves normal non-VPN access, so retain the cloud-side restrictions on public services. Re-running install preserves existing settings and peers; it does not prompt to change the stored network or endpoint.
Complete automated gateway script
Save the following code as vpn-gateway.py on a fresh Ubuntu gateway. It installs and configures WireGuard, forwarding, LAN NAT, startup services and the first device. It also installs the customer-vpn command for adding and revoking later devices.
The endpoint prompt defaults to the example vpn.example.com; enter your real endpoint when asked. The public example default is the only change from the script used in the successful deployment.
#!/usr/bin/env python3 """Dedicated Ubuntu WireGuard gateway. sudo python3 vpn-gateway.py install Commands: install, add-peer, revoke-peer NAME, client NAME, status, firewall. All VPN peers may initiate any IPv4 protocol to the configured customer LAN. Does not expose the whole SDDC or provide an Internet exit VPN. """ import argparse import base64 import fcntl import ipaddress import json import os from pathlib import Path import re import shutil import subprocess import sys import tempfile ROOT = Path('/etc/customer-vpn') STATE = ROOT / 'state.json' WG = Path('/etc/wireguard/wg0.conf') SELF = Path('/usr/local/sbin/customer-vpn') TABLE = 'customer_vpn' def run(*args, data=None, check=True): return subprocess.run(args, input=data, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check).stdout.strip() def put(path, text, mode=0o600): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) fd, name = tempfile.mkstemp(dir=path.parent) try: os.fchmod(fd, mode) with os.fdopen(fd, 'w') as out: out.write(text) out.flush() os.fsync(out.fileno()) os.replace(name, path) finally: if os.path.exists(name): os.unlink(name) def ask(label, default=None): value = input(label + (f' [{default}]' if default else '') + ': ').strip() value = value or default if not value: raise ValueError('A value is required.') return value def pubkey(value): if len(base64.b64decode(value, validate=True)) != 32: raise ValueError('WireGuard public key must decode to 32 bytes.') return value def peername(value): if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}', value): raise ValueError('Device name: 1–32 letters, digits, hyphens or underscores.') return value def endpoint(value): try: ipaddress.IPv4Address(value) except ValueError: if not re.fullmatch(r'(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?', value): raise ValueError('Enter a public IPv4 address or DNS hostname, without a port.') return value def validate_networks(lan, tunnel): a, b = ipaddress.IPv4Network(lan), ipaddress.IPv4Network(tunnel) if a.overlaps(b) or b.prefixlen != 24: raise ValueError('VPN must be a /24 and must not overlap the customer LAN.') return a, b def load(): if not STATE.exists(): raise ValueError('Run install first.') return json.loads(STATE.read_text()) def config(s): lines = ['[Interface]', f"PrivateKey = {s['private_key']}", f"Address = {s['gateway']}/24", f"ListenPort = {s['port']}", ''] for name, p in sorted(s['peers'].items()): lines += [f'# {name}', '[Peer]', f"PublicKey = {p['key']}", f"AllowedIPs = {p['ip']}/32", ''] return '\n'.join(lines) + '\n' def rules(s): lan, vpn, iface = s['lan'], s['vpn'], s['interface'] # Only own table is replaced. Other host input/output rules remain untouched. return f'''table inet {TABLE} {{ chain input {{ type filter hook input priority -10; policy accept; iifname "wg0" ip saddr {vpn} meta l4proto icmp counter accept iifname "wg0" ip saddr {vpn} tcp dport 22 counter accept iifname "wg0" counter drop udp dport {s['port']} counter accept }} chain forward {{ type filter hook forward priority -10; policy accept; iifname "wg0" oifname "{iface}" ip saddr {vpn} ip daddr {lan} ct state new,established,related counter accept iifname "{iface}" oifname "wg0" ip saddr {lan} ip daddr {vpn} ct state established,related counter accept iifname "wg0" counter drop oifname "wg0" counter drop }} chain postrouting {{ type nat hook postrouting priority srcnat; policy accept; oifname "{iface}" ip saddr {vpn} ip daddr {lan} counter masquerade }} }} ''' def firewall(s): exists = subprocess.run(['nft', 'list', 'table', 'inet', TABLE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 transaction = (f'delete table inet {TABLE}\n' if exists else '') + rules(s) run('nft', '-c', '-f', '-', data=transaction) run('nft', '-f', '-', data=transaction) def sync(): stripped = run('wg-quick', 'strip', 'wg0') fd, path = tempfile.mkstemp(dir=ROOT) try: with os.fdopen(fd, 'w') as out: out.write(stripped + '\n') run('wg', 'syncconf', 'wg0', path) finally: os.unlink(path) def commit(s, live=True): old_state = STATE.read_text() if STATE.exists...
