> ## Content Index
> Fetch the complete content index at: https://blogs.afaqjaved.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Your Own VPN for the Price of a Coffee: Self-Hosting WireGuard on a VPS
- URL: https://blogs.afaqjaved.com/your-own-vpn-for-the-price-of-a-coffee-self-hosting-wireguard-on-a-vps/
- Published: 2026-10-10T10:05:43.000Z
- Updated: 2026-10-10T10:11:32.000Z
- Author: Afaq Javed

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](https://www.youtube.com/watch?v=HfwV4XmQVMU&t=1321s&ref=blogs.afaqjaved.com). In the last post, [Stop Renting Your Stack: Lock Down a VPS in 30 Minutes](https://blogs.afaqjaved.com/), 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](https://github.com/AfaqJaved/self-hosting?ref=blogs.afaqjaved.com), 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:

```bash
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.

```bash
# 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](https://docs.docker.com/engine/install/ubuntu/?ref=blogs.afaqjaved.com). 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:

```bash
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

```bash
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:

```bash
docker volume create portainer_data
```

Then the container itself, attached to our `nginx` network:

```bash
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:

```bash
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.

```bash
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:

```bash
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](https://github.com/AfaqJaved/self-hosting?ref=blogs.afaqjaved.com):

```yaml
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

```bash
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](https://www.wireguard.com/install/?ref=blogs.afaqjaved.com).
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:

```bash
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](https://github.com/AfaqJaved/self-hosting?ref=blogs.afaqjaved.com): 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](https://www.youtube.com/watch?v=HfwV4XmQVMU&t=1321s&ref=blogs.afaqjaved.com). And if this saved you a subscription, subscribe to The CTO's Notebook so the next build lands in your inbox.