Your Own VPN for the Price of a Coffee: Self-Hosting WireGuard on a VPS
You can run a private VPN on a VPS you already pay for, with a web dashboard, in under an hour. No monthly VPN subscription, no shared IP that half the internet has already been banned on, and nobody else's logs.
This is the written version of my live stream, Self Hosting Crash Course: Deploy your first VPN in cheapest way. In the last post, Stop Renting Your Stack: Lock Down a VPS in 30 Minutes, we hardened a fresh server. This post picks up exactly where that one ended.
What you need before you start
- A VPS that is already locked down: a non-root sudo user, SSH keys only, root login off, and
ufwon. If you have not done that yet, do the previous post first. - Ubuntu 24.04 on the server. Other versions work, but the commands below were run on 24.04.
- A domain name you control. Mine is registered at GoDaddy.
- A free Cloudflare account.
What we are building
By the end you will have three containers on one small server:
- Portainer, a web UI to manage Docker so you are not living in the terminal.
- Cloudflare Tunnel (
cloudflared), which puts your dashboards on HTTPS subdomains without opening ports 80 or 443. - wg-easy, which is WireGuard plus a clean admin panel for creating clients and QR codes.
Why WireGuard? It is small, fast, built into the Linux kernel, and the client apps are free on every platform. The only hard part is managing keys and config files by hand, and wg-easy removes that.
How the pieces fit together
Two kinds of traffic reach the server, and they take different roads.
Traffic | Road in | Port opened on the VPS |
|---|---|---|
Dashboards ( | Browser to Cloudflare, then down the tunnel to the container | None. The tunnel dials out. |
The VPN itself | WireGuard client straight to the server's public IP |
|
Every container joins one shared Docker network called nginx. On that network containers find each other by name, so the tunnel can reach http://portainer:9000 or http://wg-easy:51821 without any of those ports being published to the internet.
The name nginx is only a label. I use the same network name across every stack in my self-hosting repo, so any service can sit behind the same front door later.
Step 1: Install Docker
SSH in as your sudo user, not root. Root login should already be refused:
ssh root@YOUR_SERVER_IP
# Permission denied (publickey).That error is the previous post doing its job. Log in with your own user and install Docker Engine from Docker's official apt repository. Do not use the docker.io package from Ubuntu's own repo; it lags behind.
# Add Docker's official GPG key
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to apt sources
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginThese commands follow the official Docker install guide for Ubuntu. Check that page if your distro differs.
Fix the "permission denied" on docker.sock
Run docker ps now and you get this:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sockYour user is not in the docker group yet. Add it, then log out and back in so the new group applies:
sudo usermod -aG docker $USER
exitAfter you reconnect, docker ps prints an empty table with no sudo. One caution: anyone in the docker group effectively has root on the box, so add only users you trust with that.
Step 2: Create the shared network
docker network create nginxOne line, and every container we start from here joins it.
Step 3: Install Portainer
Portainer gives you a browser view of every container, volume, network and log on the server. First a volume, so its settings survive restarts:
docker volume create portainer_dataThen the container itself, attached to our nginx network:
docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always \
--network nginx \
-v /var/run/docker.sock:/var/run/docker.sock \
-v portainer_data:/data \
portainer/portainer-ee:ltsWhat each part does:
--restart=alwaysbrings Portainer back after a reboot or a crash.--network nginxis the one flag I added to the stock command. Without it the tunnel cannot find Portainer by name.-v /var/run/docker.sock:...lets Portainer control Docker. That is real power, which is why the login in front of it matters.portainer-ee:ltsis the Business Edition. It asks for a license key on first login, and Portainer gives a free key for up to three nodes. If you would rather skip the key, swap the image forportainer/portainer-ce:lts. I use Business Edition because it deploys stacks from Git with nicer controls, which we use in Step 5.
Confirm it is up:
docker psDo not open the dashboard by IP address. We will give it a proper HTTPS hostname in the next step.
Step 4: Put it behind a Cloudflare Tunnel
A tunnel is an outbound connection from your server to Cloudflare. Visitors hit Cloudflare over HTTPS, and Cloudflare passes the request down that connection. You get free TLS certificates, your server's IP stays out of DNS for these hostnames, and ports 80 and 443 stay closed.
Move your domain's DNS to Cloudflare
- In the Cloudflare dashboard, add your domain and pick the Free plan.
- Cloudflare scans your existing DNS records. Review them and continue.
- Cloudflare shows you two nameservers. Copy them.
- At your registrar (GoDaddy for me: Domain, DNS, Nameservers, Change Nameservers), replace the default nameservers with Cloudflare's two.
- Wait. Mine switched in a few minutes, but it can take hours. Cloudflare's overview page says the domain is active when it is done.
The registrar will warn you that changing nameservers is risky. It is only risky if the domain already serves a live site and you skipped the record review in point 2.
Create the tunnel
- In Cloudflare, open Zero Trust, then Networks, then Tunnels, and create a tunnel of type Cloudflared.
- Give it a name and choose Docker as the environment.
- Cloudflare shows a
docker runcommand with a long token. Copy the token.
Run the connector on the server, with two changes to Cloudflare's stock command: run it detached with a restart policy, and join the nginx network.
docker run -d --name cloudflared --restart=always \
--network nginx \
cloudflare/cloudflared:latest tunnel --no-autoupdate run --token YOUR_TUNNEL_TOKENNo -p flags at all. The connector only makes outbound connections.
Treat that token like a password. Anyone who has it can run a connector for your tunnel. Keep it out of screenshots, recordings and Git, and if it ever leaks, delete the tunnel and create a new one.
Publish Portainer
In the tunnel's settings, add a published application (a public hostname):
Field | Value |
|---|---|
Subdomain |
|
Domain | your domain |
Service |
|
Cloudflare creates the DNS record for you. Open https://portainer.yourdomain.com and create the admin user. Portainer insists on a password of at least 12 characters, so let a password manager generate one.
If you see "New Portainer installation ... timed out"
Portainer locks its setup page if nobody creates the admin user within a few minutes of first start. It happened to me on the stream because the DNS change took longer than the timer. The fix is one command, then reload the page:
docker restart portainerStep 5: Deploy WireGuard with wg-easy
Now the part you came for. We deploy wg-easy as a Portainer stack pulled straight from Git, so the server never holds a hand-edited compose file.
The compose file
This is wireguard/docker-compose.yml from my self-hosting repo:
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
environment:
- WG_HOST=${WG_HOST}
# Unattended setup: creates the admin account on first boot.
# 2FA (TOTP) can't be set via env vars - enable it after login from the Account page in the UI.
- INIT_ENABLED=true
- INIT_USERNAME=${WG_USERNAME}
- INIT_PASSWORD=${WG_PASSWORD}
- INIT_HOST=${WG_HOST}
- INIT_PORT=51820
- DISABLE_IPV6=true
volumes:
- ~/.wg-easy:/etc/wireguard
ports:
- "51820:51820/udp"
- "51821:51821/tcp"
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped
networks:
- nginx
networks:
nginx:
external: trueThe lines worth understanding:
INIT_*variables create the admin account on first boot, so there is no setup wizard sitting open on the internet.51820/udpis the WireGuard port. It is the only port in this whole guide that must be reachable from outside.51821/tcpis the admin panel. We reach it through the tunnel, not through this port. See the hardening section below.NET_ADMINandSYS_MODULElet the container create thewg0interface and manage routing.net.ipv4.ip_forward=1lets the container forward your traffic out to the internet. Without it you connect and then nothing loads.networks: nginxwithexternal: truejoins the network from Step 2 instead of creating a new one.
Three variables
Variable | Value |
|---|---|
| Your VPS public IP address |
| The admin username, for example |
| A long, unique admin password |
Set WG_HOST to the server's IP, not to a Cloudflare hostname. Clients connect to this address over UDP, and Cloudflare's proxy and tunnel in this setup carry web traffic only.
Deploy it from Portainer
- In Portainer, go to Stacks and add a stack. I named mine
vpn. - Choose Repository as the build method.
- Repository URL:
https://github.com/AfaqJaved/self-hosting(or your own fork). It is public, so no credentials are needed. - Compose path:
wireguard/docker-compose.yml. - Under Environment variables, add the three variables from the table.
- Deploy the stack.
The container shows starting for a few seconds and then healthy. Open its logs in Portainer and you should see the WireGuard interface wg0 start and the web UI listening on port 51821.
Prefer the terminal? Clone the repo, put the three values in a .env file next to the compose file, and run docker compose up -d from the wireguard folder.
Open the WireGuard port
sudo ufw allow 51820/udpIf your VPS provider has its own firewall panel, allow 51820/udp there too. That panel sits in front of the server and ufw cannot see it.
Step 6: Publish the VPN dashboard
Back in the Cloudflare tunnel, add a second published application:
Field | Value |
|---|---|
Subdomain |
|
Domain | your domain |
Service |
|
Open https://vpn.yourdomain.com and sign in with the username and password you set in the stack. The first thing to do after login is open the Account page and turn on two-factor authentication. It cannot be set from environment variables, so it is a manual step.
Step 7: Create a client and connect
- In the wg-easy dashboard, click New Client.
- Name it after the device, for example
MacbookorPhone. An expiry date is optional and handy for guests. - On a laptop, download the
.conffile. On a phone, show the QR code. - Install the official WireGuard app from wireguard.com/install.
- Import the config file (or scan the QR code) and activate the tunnel.
Create one client per device. If a laptop is lost, you delete that one client and nothing else is affected.
Prove it works
Check your public IP before and after you switch the tunnel on:
curl ifconfig.meBefore, mine showed my home ISP in Pakistan. After, it showed the VPS address, and a geolocation lookup placed me in Abu Dhabi, where my server lives. Your traffic now leaves the internet from your VPS, and the hop between your device and the server is encrypted.
Tighten it up
The setup above works. These four changes make it safer.
- Stop publishing the dashboard ports. Docker writes its own firewall rules, so a published port is reachable from the internet even when
ufwsays deny. With the compose file as written, the wg-easy panel also answers onhttp://YOUR_SERVER_IP:51821over plain HTTP. Since the tunnel reaches it over the Docker network, delete the"51821:51821/tcp"line in your fork, or change it to"127.0.0.1:51821:51821/tcp". The same applies to Portainer's-p 8000:8000 -p 9443:9443: you can drop both flags. - Put Cloudflare Access in front of both dashboards. A free Zero Trust policy can require an email one-time code before anyone even sees the login page.
- Back up the wg-easy data folder. It holds
wg-easy.dbandwg0.conf, which is every client and key. Because the stack runs through Portainer,~/.wg-easylanded at/.wg-easyon my server, not in my home folder. Rundocker inspect wg-easyand look under Mounts to see where yours is. - Pin and update on purpose. The image tag
:15tracks wg-easy version 15. Pull and redeploy from Portainer every so often, and read the release notes before jumping a major version.
When it does not work
Symptom | Likely cause | Fix |
|---|---|---|
Client says connected, but no handshake and no internet |
| Open it in |
Handshake works, pages do not load | Forwarding is off or DNS is wrong | Keep the two |
Client tries to reach the wrong address |
| Set it to the VPS public IP and redeploy |
Tunnel hostname returns 502 |
| Confirm both containers are on the |
Portainer says the instance timed out | Admin user was not created in time |
|
What this costs, and what it does not do
The only bill is the VPS, and you were already paying it. Docker, Portainer's three-node license, Cloudflare's free plan, wg-easy and the WireGuard apps cost nothing.
Be clear about what you bought, though. A self-hosted VPN protects you on public Wi-Fi, gives you a fixed IP you can allowlist on work systems, and gets you into your own private services. It does not make you anonymous. The IP belongs to a server rented in your name, and your VPS provider can see traffic leaving it.
What's next
The same pattern, one compose file, one stack and one tunnel hostname, works for every other service in the self-hosting repo: n8n, Ghost, Umami, Mattermost and more. The VPN is only the first tenant on this server.
If you would rather watch than read, the full walkthrough is in the live stream. And if this saved you a subscription, subscribe to The CTO's Notebook so the next build lands in your inbox.