Your Own VPN for the Price of a Coffee: Self-Hosting WireGuard on a VPS

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 ufw on. 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:

  1. Portainer, a web UI to manage Docker so you are not living in the terminal.
  2. Cloudflare Tunnel (cloudflared), which puts your dashboards on HTTPS subdomains without opening ports 80 or 443.
  3. 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 (portainer.yourdomain.com, vpn.yourdomain.com)

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

51820/udp

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

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

Your 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
exit

After 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 nginx

One 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_data

Then 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:lts

What each part does:

  • --restart=always brings Portainer back after a reboot or a crash.
  • --network nginx is 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:lts is 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 for portainer/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 ps

Do 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

  1. In the Cloudflare dashboard, add your domain and pick the Free plan.
  2. Cloudflare scans your existing DNS records. Review them and continue.
  3. Cloudflare shows you two nameservers. Copy them.
  4. At your registrar (GoDaddy for me: Domain, DNS, Nameservers, Change Nameservers), replace the default nameservers with Cloudflare's two.
  5. 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

  1. In Cloudflare, open Zero Trust, then Networks, then Tunnels, and create a tunnel of type Cloudflared.
  2. Give it a name and choose Docker as the environment.
  3. Cloudflare shows a docker run command 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_TOKEN

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

portainer

Domain

your domain

Service

http://portainer:9000

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 portainer

Step 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: true

The lines worth understanding:

  • INIT_* variables create the admin account on first boot, so there is no setup wizard sitting open on the internet.
  • 51820/udp is the WireGuard port. It is the only port in this whole guide that must be reachable from outside.
  • 51821/tcp is the admin panel. We reach it through the tunnel, not through this port. See the hardening section below.
  • NET_ADMIN and SYS_MODULE let the container create the wg0 interface and manage routing.
  • net.ipv4.ip_forward=1 lets the container forward your traffic out to the internet. Without it you connect and then nothing loads.
  • networks: nginx with external: true joins the network from Step 2 instead of creating a new one.

Three variables

Variable

Value

WG_HOST

Your VPS public IP address

WG_USERNAME

The admin username, for example admin

WG_PASSWORD

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

  1. In Portainer, go to Stacks and add a stack. I named mine vpn.
  2. Choose Repository as the build method.
  3. Repository URL: https://github.com/AfaqJaved/self-hosting (or your own fork). It is public, so no credentials are needed.
  4. Compose path: wireguard/docker-compose.yml.
  5. Under Environment variables, add the three variables from the table.
  6. 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/udp

If 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

vpn

Domain

your domain

Service

http://wg-easy:51821

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

  1. In the wg-easy dashboard, click New Client.
  2. Name it after the device, for example Macbook or Phone. An expiry date is optional and handy for guests.
  3. On a laptop, download the .conf file. On a phone, show the QR code.
  4. Install the official WireGuard app from wireguard.com/install.
  5. 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.me

Before, 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 ufw says deny. With the compose file as written, the wg-easy panel also answers on http://YOUR_SERVER_IP:51821 over 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.db and wg0.conf, which is every client and key. Because the stack runs through Portainer, ~/.wg-easy landed at /.wg-easy on my server, not in my home folder. Run docker inspect wg-easy and look under Mounts to see where yours is.
  • Pin and update on purpose. The image tag :15 tracks 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

51820/udp is blocked

Open it in ufw and in your provider's firewall panel

Handshake works, pages do not load

Forwarding is off or DNS is wrong

Keep the two sysctls lines; check the DNS setting in the wg-easy admin panel

Client tries to reach the wrong address

WG_HOST is a proxied hostname or a typo

Set it to the VPS public IP and redeploy

Tunnel hostname returns 502

cloudflared cannot find the container

Confirm both containers are on the nginx network and the service name matches the container name

Portainer says the instance timed out

Admin user was not created in time

docker restart portainer

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.