Setting up your own VPN server
• Published:At work, we've had to come up with a way to access devices hiding behind firewalls at a few different locations. This is a great fit for a VPN.
Let's explore how to set up a manageable private VPN in the public cloud. We'll leverage podman and systemd to manage a container.
The plan
We'll use WireGuard as our VPN solution, it's fast, simple, modern and secure, and manage it with wg-easy.
Caddy will provide TLS certificates to secure our frontend.
You'll also need a domain.
Setting up the VM
You can use any public cloud provider you want. You don't need a powerful VM for a VPN, so a cheap one will do just fine.
This guide focuses on Debian-based systems (specifically Ubuntu), as that's what we used. So if you want to follow along select that.
Once the VM is running, note its IP and update your DNS records to point to the machine. Add the following DNS entries to wherever you have those:
- an A record e.g.
vpn.example.comwith your VMs IP - a CNAME record for wg-easy frontend, e.g.
wg-easy.example.comorwg-easy.vpn.example, in either case it should point tovpn.example.com- you could also skip this second domain if all you'll host on this VM is the
wg-easyfrontend, if you do, remember to usevpn.example.comin your Caddyfile later.
- you could also skip this second domain if all you'll host on this VM is the
Some cloud providers set up the SSH access keys for you, but if they don't, open a web console, edit ~/.ssh/authorized_keys and paste in your SSH key.
Now you can use regular SSH to connect to the machine.
First make sure SSH server does not allow password login, by inspecting the config at /etc/ssh/ssh_config and setting PasswordAuthentication no if its not set already.
Then you should update it:
apt update
apt upgrade
And install the dependencies we'll need:
apt install -y podman caddy
Firewall setup
You'll need to allow certain ports:
| Protocol | Type | Port | Source |
|---|---|---|---|
| SSH | TCP | 22 | All IPv4 |
| HTTP | TCP | 80 | All IPv4 |
| HTTPS | TCP | 443 | All IPv4 |
| WireGuard | UDP | 51820 | All IPv4 |
Installing wg-easy
Usually I would advocate for installing it as a pure systemd unit with a binary run as a dynamic/service user, but wg-easy does not have that option.
They only support deploying via a container.
So we'll follow their guide here and modify it a bit for our needs.
# Create folders for our files
mkdir -p /etc/containers/systemd/wg-easy
mkdir -p /etc/containers/volumes/wg-easy
We modified the container file a bit to drop the IPv6 stuff, since some of our ISPs don't have that yet (I know!). So we also don't need the network.
As per the guide above, we'll define our container using a .container file.
This is a feature of Podman's quadlet generator, which allows systemd to manage containers directly from a simple definition file.
Its structure is the same as it is for regular systemd unit files.
# /etc/containers/systemd/wg-easy/wg-easy.container
[Container]
ContainerName=wg-easy
Image=ghcr.io/wg-easy/wg-easy:15
AutoUpdate=registry
Volume=/etc/containers/volumes/wg-easy:/etc/wireguard:Z
PublishPort=51820:51820/udp
PublishPort=51821:51821/tcp
# Disable IPv6, as some ISPs don't have full support yet.
Environment=DISABLE_IPV6=true
AddCapability=NET_ADMIN
AddCapability=SYS_MODULE
AddCapability=NET_RAW
Sysctl=net.ipv4.ip_forward=1
Sysctl=net.ipv4.conf.all.src_valid_mark=1
[Install]
WantedBy=default.target
We indicate that we want additional modules loaded with the following file:
# /etc/modules-load.d/wg-easy.conf
wireguard
nft_masq
You can load these modules with either a reboot of the VM or by running systemctl restart systemd-modules-load.service.
Then we can start the service:
systemctl daemon-reload
systemctl start wg-easy
Verify that the service is running with systemctl status wg-easy.
Setup Caddy
For setting up Caddy all we need is a Caddyfile.
# /etc/caddy/Caddyfile
wg-easy.vpn.example.com {
reverse_proxy localhost:51821
}
Then reload the service and check that all is good:
systemctl reload caddy
systemctl status caddy
You can also inspect the logs and check that it got the TLS certificates with journalctl -eu caddy.
Verify we can access wg-easy by going to https://wg-easy.vpn.example.com.
I strongly urge you to set up 2FA on the wg-easy frontend, especially if you'll be exposing it on the WWW.
Configure WireGuard via wg-easy
The configuration will depend on your specific needs. This setup is highly flexible, allowing you to connect single devices or entire networks.
But there is one last step that you should do according to the linked guide and that is to update the hooks. You can find these in the Administrator > Admin panel > Hooks.
PostUp hook:
nft add table inet wg_table;
nft add chain inet wg_table prerouting { type nat hook prerouting priority 100 \; };
nft add chain inet wg_table postrouting { type nat hook postrouting priority 100 \; };
nft add rule inet wg_table postrouting ip saddr oifname masquerade;
nft add chain inet wg_table input { type filter hook input priority 0 \; policy accept \; };
nft add rule inet wg_table input udp dport accept;
nft add rule inet wg_table input tcp dport accept;
nft add chain inet wg_table forward { type filter hook forward priority 0 \; policy accept \; };
nft add rule inet wg_table forward iifname "wg0" accept;
nft add rule inet wg_table forward oifname "wg0" accept;
This configuration enables the WireGuard server to:
- Accept VPN connections
- Route client traffic to the internet (NAT/masquerade)
- Allow forwarding between the VPN and other networks
- Accept management interface connections
PostDown hook:
nft delete table inet wg_table
Then, go to Admin panel > General and make sure that the Host and the Port are correct.
I've set the AllowedIPs to 10.99.0.0/16.
This is the global default setting that will get set for all clients.
It tells the clients what traffic to route into the VPN.
You would set this to 0.0.0.0/0, if you'd like to route all traffic through the VPN.
I've used same IP in Admin panel > Interface > Change CIDR > IPv4. This defines the WireGuard subnet.
Now you can add your first client.
Click the New button on the dashboard, give client a name and an (option) expiration date.
If you open up the client by clicking on its name you can see that an IP from the WireGuard network has been allocated to it.
I will change this one to 10.99.1.1 for my scheme.
The AllowedIPs is here if we want to override the default from the Admin panel.
The ServerAllowedIPs needs to be filled out if we want the server to route traffic to this client (or the network connected to it).
I foresee that I will want to route more than just the client IP to this client (for its network), so I will input 10.99.1.0/24 here.
When you're done, wg-easy makes it simple to share the client config to each client with either a QR code, a file or a link.
Testing the connection
Downloading the config file for my client, I import it into my WireGuard client installed on my machine and turn it on.
If everything works, you will see that a handshake has happened and on the wg-easy dashboard you will see that some data has started flowing through the VPN.
Next steps
Security hardening
- use a separate non-root user for the service
- restrict the firewall even further (e.g. to allow connections only from specific IPs)
Ad blocking
You can add some DNS-based Ad blocking, like the excelent Pi-hole.
Monitoring and Alerting
You might want to set up monitoring and alerting to see how much traffic, bandwidth, CPU or RAM you're using.
Automate the provisioning process
At work we wrapped this process up in a script to make it painless to deploy this setup to another machine, if needed. If you'll add more than just a few services, then going with Ansible or Terraform might be a better fit.
Conclusion
We saw how easy it is to set up your own and private VPN server, that you can easily share with your friends and family. It runs on a cheap public cloud VM that is cheaper than leading hosted VPN solutions.
Try it out and let me know how well it works for you