A hybrid cloud combines your own VPS or bare metal servers with services from a public cloud such as AWS, connected over a private network so they can talk as if they were in the same data center. The usual reason is cost and control: steady workloads run on fixed-price servers, while managed databases, object storage or burst capacity come from the cloud. In this tutorial you will link an Ubuntu 24.04 VPS to an AWS VPC with a site-to-site WireGuard tunnel, route traffic to private VPC addresses, lock it down with firewall rules, and review how to place workloads on each side.
Prerequisites
To follow this tutorial you need:
- A VPS running Ubuntu 24.04 LTS with a public IPv4 address, for example a CubePath VPS, and a non-root user with
sudoprivileges. - An AWS account with a VPC and at least one private instance or service you want to reach. The examples use the default VPC range
172.31.0.0/16; replace it with your VPC CIDR. - The AWS CLI v2 configured on your workstation (
aws configure) with permissions to manage EC2 instances, security groups and route tables. - A VPN address range that does not overlap with either network. This guide uses
10.100.0.0/24: the VPS gets10.100.0.1and the AWS gateway10.100.0.2.
NoteAWS also sells a managed Site-to-Site VPN (IPsec) that terminates on a Virtual Private Gateway. It costs more per hour but needs no gateway instance. The WireGuard approach here is cheaper and simpler to debug, and the same design works with GCP or Azure.
Architecture overview
The design has three parts:
| Component | Where | Role |
|---|---|---|
wg0 on the VPS | Your VPS | Tunnel endpoint, address 10.100.0.1 |
| Gateway instance | Small EC2 instance (Ubuntu 24.04) in a public subnet of the VPC | Tunnel endpoint 10.100.0.2, forwards packets between the tunnel and the VPC |
| VPC route | VPC route table | Sends traffic for 10.100.0.0/24 to the gateway instance |
Traffic from the VPS to 172.31.x.x goes through the tunnel to the gateway, which forwards it inside the VPC. Replies from VPC resources to 10.100.0.1 follow the VPC route back to the gateway and into the tunnel.
Step 1 - Launching and preparing the AWS gateway
Launch a small Ubuntu 24.04 EC2 instance (a t3.micro or t4g.micro is enough for moderate traffic) in a public subnet of the VPC, with a public IP or Elastic IP. Its security group needs to accept WireGuard only from your VPS:
aws ec2 authorize-security-group-ingress \
--group-id your_gateway_sg_id \
--protocol udp --port 51820 \
--cidr your_vps_ip/32
EC2 drops packets whose source or destination is not the instance's own address. A router must forward other addresses, so disable that check on the gateway:
aws ec2 modify-instance-attribute --instance-id your_gateway_instance_id --no-source-dest-check
Confirm the change:
aws ec2 describe-instances --instance-ids your_gateway_instance_id \
--query 'Reservations[0].Instances[0].SourceDestCheck'
false
Step 2 - Installing WireGuard and generating keys
Run these commands on both the VPS and the gateway instance. Install WireGuard:
sudo apt update
sudo apt install wireguard
Generate a key pair. The private key is written with 0600 permissions so only root can read it:
wg genkey | sudo tee /etc/wireguard/private.key > /dev/null
sudo chmod 600 /etc/wireguard/private.key
sudo cat /etc/wireguard/private.key | wg pubkey | sudo tee /etc/wireguard/public.key
The last command prints the public key. Note the public key of each machine; you will paste the VPS key into the gateway config and the other way round. Never copy private keys between machines.
Step 3 - Configuring the tunnel on the VPS
Create the WireGuard interface file on the VPS:
sudo nano /etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.1/24
ListenPort = 51820
PrivateKey = vps_private_key
[Peer]
# AWS gateway
PublicKey = gateway_public_key
Endpoint = gateway_public_ip:51820
AllowedIPs = 10.100.0.2/32, 172.31.0.0/16
PersistentKeepalive = 25
Replace vps_private_key with the content of /etc/wireguard/private.key on the VPS, gateway_public_key with the gateway's public key and gateway_public_ip with its public address. AllowedIPs does two jobs: wg-quick adds routes for these ranges through wg0, and WireGuard only accepts packets from the peer with source addresses in them. Including the VPC CIDR is what makes the whole VPC reachable.
Allow WireGuard from the gateway through UFW:
sudo ufw allow from gateway_public_ip to any port 51820 proto udp
Step 4 - Configuring the tunnel and forwarding on the gateway
On the gateway instance, enable IP forwarding so it can route packets between the tunnel and the VPC. Create a sysctl drop-in:
sudo nano /etc/sysctl.d/99-wireguard-forward.conf
net.ipv4.ip_forward = 1
Load it:
sudo sysctl --system
Create the WireGuard config on the gateway:
sudo nano /etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.2/24
ListenPort = 51820
PrivateKey = gateway_private_key
[Peer]
# Hybrid VPS
PublicKey = vps_public_key
Endpoint = your_vps_ip:51820
AllowedIPs = 10.100.0.1/32
On both machines, enable and start the interface:
sudo systemctl enable --now wg-quick@wg0
Check the tunnel on the VPS:
sudo wg show
interface: wg0
public key: ...
listening port: 51820
peer: ...
endpoint: gateway_public_ip:51820
allowed ips: 10.100.0.2/32, 172.31.0.0/16
latest handshake: 12 seconds ago
transfer: 1.24 KiB received, 2.10 KiB sent
persistent keepalive: every 25 seconds
A recent latest handshake means the keys and firewall are correct. Ping the gateway's tunnel address:
ping -c 3 10.100.0.2
Step 5 - Routing the VPC back to the VPS
Other instances in the VPC do not yet know that 10.100.0.0/24 lives behind the gateway. Add a route to every route table used by subnets that must reach the VPS:
aws ec2 create-route \
--route-table-id your_route_table_id \
--destination-cidr-block 10.100.0.0/24 \
--instance-id your_gateway_instance_id
{
"Return": true
}
Security groups still apply to tunnel traffic. On the instance or service you want to reach (for example an RDS database or a private EC2 instance), allow only the specific port from the VPN range. For a MySQL-compatible database:
aws ec2 authorize-security-group-ingress \
--group-id your_private_resource_sg_id \
--protocol tcp --port 3306 \
--cidr 10.100.0.0/24
Test from the VPS against a private address in the VPC. For a database:
nc -zv 172.31.20.15 3306
Connection to 172.31.20.15 3306 port [tcp/mysql] succeeded!
If you also need VPC resources to open connections to services on the VPS, two things are required: the gateway's security group must allow inbound traffic from the VPC CIDR (those packets enter the gateway before being forwarded into the tunnel), and UFW on the VPS must allow them. Allow them in UFW by source range and port only, for example to let a cloud Prometheus scrape node_exporter:
sudo ufw allow in on wg0 from 172.31.0.0/16 to any port 9100 proto tcp
Step 6 - Deciding what runs where
With the private link in place, place each workload where it makes the most sense. A reasonable starting point:
| Workload | Usually best on | Why |
|---|---|---|
| Web and application servers with steady traffic | VPS | Fixed monthly price, no per-request or egress surprises |
| CI runners, build servers, background workers | VPS | Always on, heavy CPU use is cheaper at a flat rate |
| Managed relational database | Cloud (RDS, Cloud SQL) or a replicated pair of VPS | Managed backups and failover versus lower cost and full control |
| Object storage and long-term archives | Object storage service | High durability without managing disks |
| Short, spiky jobs (ML training, batch processing) | Cloud on-demand or spot capacity | Pay only while the job runs |
Two rules help avoid surprises:
- Keep chatty components together. A web server making dozens of database queries per request should sit close to its database. Every query across the tunnel adds the round-trip time between the VPS location and the cloud region.
- Watch egress. Traffic leaving AWS toward your VPS is billed as data transfer out. Syncing large datasets from the cloud to the VPS every day can cost more than the servers.
Measure the latency of the link before moving anything latency-sensitive:
ping -c 10 172.31.20.15
For this test the target's security group must allow ICMP from 10.100.0.0/24. Choose the cloud region closest to your VPS data center to keep this number low.
Troubleshooting
No handshake in wg show. Check that UDP port 51820 is allowed in the gateway security group and in UFW on the VPS, that each side has the other's public key (not its own), and that the Endpoint addresses are correct. sudo journalctl -u wg-quick@wg0 shows errors when the interface starts.
The gateway answers but VPC instances do not. Confirm that source/destination check is disabled on the gateway, that net.ipv4.ip_forward is 1 (sysctl net.ipv4.ip_forward), that the route to 10.100.0.0/24 exists in the subnet's route table, and that the target instance's security group allows traffic from 10.100.0.0/24.
The tunnel drops after the gateway restarts. If the gateway has no Elastic IP, its public address changes when it stops and starts. Assign an Elastic IP and update Endpoint on the VPS.
Conclusion
Your VPS and AWS VPC now share a private, encrypted network: the VPS reaches private cloud resources through a WireGuard gateway, and VPC instances can route back to it with nothing exposed to the public internet beyond one UDP port. That link is the base of any hybrid architecture.
As next steps, move services that only need internal access behind the tunnel and close their public ports, add a second gateway in another availability zone for redundancy, and monitor the tunnel handshake age and latency from your existing monitoring stack.
