2025-11-28

Build Your Own Free DIY VPN in Minutes (with Pure Python)

Networking, Python, Security · PLEX Lab

When people say “VPN”, they often mean a commercial app that hides their IP. Under the hood, though, a VPN is “just” an encrypted tunnel plus routing rules. In this article, you’ll build a DIY VPN-style tunnel using only Python: one server script, one client script, and a browser configured to use your tunnel as a proxy.

This isn’t a full, audited replacement for OpenVPN, WireGuard or IPSec. It’s a learning-grade, open tunnel that shows you how VPNs work and gives you a private encrypted pipe that you control, with a practical twist: by running servers in different regions, you can change the location websites think you’re in simply by switching which Python server you connect to.

What “DIY VPN” means here

In this guide, “DIY VPN” means:

  • We build an encrypted TCP tunnel between a client and a server using Python’s socket and ssl modules.
  • The client exposes a local “proxy” port (for example localhost:8080).
  • Your browser sends traffic to that local proxy, which forwards through the encrypted tunnel to the server.
  • By running servers in different locations (home, Sydney, Frankfurt, New York, etc.), you choose where your traffic appears to originate.

No OS-specific VPN packages, no VPN GUI, no extra daemons—just Python on both ends plus your browser.

1. Concept: turning Python into a mini VPN

A production VPN protocol (like OpenVPN, WireGuard, IPSec) runs in kernel or user space, encrypts and authenticates packets, and presents a virtual network interface. We’re going to build the core idea in a simpler form:

  • A Python tunnel server running on a machine with external network access.
  • A Python tunnel client running on your laptop/desktop.
  • An encrypted TCP connection between client and server using Python’s ssl wrapper.
  • A small proxy protocol: client tells the server which host:port it wants, then relays bytes.

From your browser’s point of view, it’s “just an HTTP proxy at localhost”; from the network’s point of view, all your browser traffic is encrypted between client and Python server, and websites use the server’s IP address to guess your location (via IP geolocation databases).

Security disclaimer

This design uses real TLS encryption via Python’s ssl module, but it’s still a simplified educational example. For serious privacy or corporate use, stick to a mature VPN solution like OpenVPN or WireGuard and follow their hardening guides. Treat this project as a learning tool and lightweight personal tunnel, not a full replacement for an audited VPN product.

2. Architecture overview

Here’s the architecture you’ll implement:

  • Server: Python program listening on some TCP port (for example 0.0.0.0:8443), wrapped in TLS.
  • Client: Python program listening locally on 127.0.0.1:8080, speaking HTTPS-like traffic to the server.
  • Browser: Configured to use HTTP proxy = 127.0.0.1:8080.

The flow for a single request:

  1. Browser asks client for a connection to example.com:443 using the HTTP CONNECT method.
  2. Client opens a TLS socket to the Python server and tells it example.com:443.
  3. Server connects to example.com:443 from wherever it’s running.
  4. Client and server relay encrypted data back and forth.

If you run one server in Sydney and another in Frankfurt, and point the client to a different server, sites will see either an Australian IP or a German IP, and geolocation services will assume you’re in that region.

3. Prerequisites

To keep this guide portable, we assume:

  • Python 3.9+ on both client and server machines (any OS is fine).
  • Ability to open an inbound TCP port on the server (for example, 8443).
  • Basic command-line comfort (running python, editing files).

We’ll use:

  • socket and selectors for non-blocking I/O.
  • ssl for TLS encryption.
  • The cryptography library as a pure-Python way to generate a self-signed certificate.

4. Step 1 – Generate a self-signed certificate in Python

We’ll generate a self-signed certificate so the server can prove its identity to the client. This uses the cryptography library, which you can install with pip.

BASH

# On both client and server machines, install cryptography once:
python -m pip install cryptography
    

Save this as generate_cert.py on the server:

PYTHON

# generate_cert.py
from datetime import datetime, timedelta

from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa

def generate_self_signed(hostname: str, cert_file: str = "server.crt", key_file: str = "server.key"):
    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

    subject = issuer = x509.Name(
        [
            x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
            x509.NameAttribute(NameOID.ORGANIZATION_NAME, "DIY VPN"),
            x509.NameAttribute(NameOID.COMMON_NAME, hostname),
        ]
    )

    cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(issuer)
        .public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(datetime.utcnow() - timedelta(minutes=5))
        .not_valid_after(datetime.utcnow() + timedelta(days=365))
        .add_extension(
            x509.SubjectAlternativeName([x509.DNSName(hostname)]),
            critical=False,
        )
        .sign(key, hashes.SHA256())
    )

    with open(cert_file, "wb") as f:
        f.write(cert.public_bytes(serialization.Encoding.PEM))

    with open(key_file, "wb") as f:
        f.write(
            key.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption(),
            )
        )

    print(f"Generated {cert_file} and {key_file} for {hostname}")

if __name__ == "__main__":
    generate_self_signed("localhost")
    

Run this on the server:

BASH

python generate_cert.py
# Creates server.crt and server.key in the current directory
    

We’ll use these files in the Python VPN server (via ssl.SSLContext).

5. Step 2 – Implement the Python VPN server

The server’s responsibilities:

  • Accept TLS-encrypted connections from clients.
  • For each client, read a small header that says which host:port to connect to.
  • Open a TCP connection to that host:port.
  • Relay bytes between client and remote host until one side closes.

Save this as vpn_server.py:

PYTHON

# vpn_server.py
import argparse
import selectors
import socket
import ssl

BUFFER_SIZE = 65536

class TunnelServer:
    def __init__(self, listen_host, listen_port, cert_file, key_file):
        self.listen_host = listen_host
        self.listen_port = listen_port;
        self.selector = selectors.DefaultSelector()

        # TLS context
        self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        self.context.load_cert_chain(certfile=cert_file, keyfile=key_file)

    def start(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((self.listen_host, self.listen_port))
        sock.listen()
        sock.setblocking(False)

        self.selector.register(sock, selectors.EVENT_READ, self.accept)

        print(f"[server] Listening on {self.listen_host}:{self.listen_port}")
        try:
            while True:
                for key, mask in self.selector.select():
                    callback = key.data
                    callback(key.fileobj, mask)
        except KeyboardInterrupt:
            print("[server] Shutting down...")
        finally:
            self.selector.close()

    def accept(self, sock, mask):
        client_sock, addr = sock.accept()
        print(f"[server] Incoming TCP connection from {addr}")
        client_sock.setblocking(True)  # wrap_socket expects blocking
        try:
            tls_client = self.context.wrap_socket(client_sock, server_side=True)
        except ssl.SSLError as e:
            print(f"[server] TLS handshake failed: {e}")
            client_sock.close()
            return

        tls_client.setblocking(False)

        # Read the first line: "host:port\n"
        try:
            request_line = self._read_line(tls_client)
        except Exception as e:
            print(f"[server] Failed to read initial line: {e}")
            tls_client.close()
            return

        if not request_line or b":" not in request_line:
            print(f"[server] Invalid request header: {request_line!r}")
            tls_client.close()
            return

        target_host, target_port_str = request_line.decode().strip().split(":", 1)
        target_port = int(target_port_str)

        print(f"[server] Connecting to target {target_host}:{target_port}")

        try:
            remote_sock = socket.create_connection((target_host, target_port))
            remote_sock.setblocking(False)
        except OSError as e:
            print(f"[server] Failed to connect to target: {e}")
            tls_client.close()
            return

        # Register both ends for bidirectional relay
        self.selector.register(tls_client, selectors.EVENT_READ, lambda s, m: self.relay(s, remote_sock))
        self.selector.register(remote_sock, selectors.EVENT_READ, lambda s, m: self.relay(s, tls_client))

    def _read_line(self, sock):
        """
        Minimal line reader for the initial header:
        bytes until we see b'\\n'.
        """
        data = b""
        while True:
            chunk = sock.recv(1)
            if not chunk:
                break
            data += chunk
            if chunk == b"\n":
                break
        return data

    def relay(self, src, dst):
        try:
            data = src.recv(BUFFER_SIZE)
        except OSError:
            data = b""
        if not data:
            # Close both directions
            self._close_pair(src, dst)
            return
        try:
            dst.sendall(data)
        except OSError:
            self._close_pair(src, dst)

    def _close_pair(self, a, b):
        for sock in (a, b):
            try:
                self.selector.unregister(sock)
            except Exception:
                pass
            try:
                sock.close()
            except Exception:
                pass

def main():
    parser = argparse.ArgumentParser(description="Python DIY VPN-style tunnel server")
    parser.add_argument("--host", default="0.0.0.0", help="Listen host (default: 0.0.0.0)")
    parser.add_argument("--port", type=int, default=8443, help="Listen port (default: 8443)")
    parser.add_argument("--cert", default="server.crt", help="TLS certificate file")
    parser.add_argument("--key", default="server.key", help="TLS private key file")
    args = parser.parse_args()

    server = TunnelServer(args.host, args.port, args.cert, args.key)
    server.start()

if __name__ == "__main__":
    main()
    

Start the server on the machine that will act as your VPN endpoint:

BASH

python vpn_server.py --host 0.0.0.0 --port 8443
    

6. Step 3 – Implement the Python VPN client (HTTP CONNECT proxy)

The client will:

  • Listen locally (for example on 127.0.0.1:8080).
  • Accept HTTP CONNECT requests from your browser.
  • Open a TLS connection to the tunnel server.
  • Send the target host:port in a header line.
  • Relay bytes between browser and server.

Save this as vpn_client.py:

PYTHON

# vpn_client.py
import argparse
import selectors
import socket
import ssl

BUFFER_SIZE = 65536

class ProxyClient:
    def __init__(self, listen_host, listen_port, server_host, server_port, server_hostname):
        self.listen_host = listen_host
        self.listen_port = listen_port
        self.server_host = server_host
        self.server_port = server_port
        self.server_hostname = server_hostname
        self.selector = selectors.DefaultSelector()

        # TLS context - we disable hostname verification for self-signed certs.
        # For production, load server CA and enable verification.
        self.context = ssl.create_default_context()
        self.context.check_hostname = False
        self.context.verify_mode = ssl.CERT_NONE

    def start(self):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((self.listen_host, self.listen_port))
        sock.listen()
        sock.setblocking(False)

        self.selector.register(sock, selectors.EVENT_READ, self.accept)

        print(f"[client] HTTP CONNECT proxy on {self.listen_host}:{self.listen_port}")
        print(f"[client] Tunnelling via {self.server_host}:{self.server_port}")
        try:
            while True:
                for key, mask in self.selector.select():
                    callback = key.data
                    callback(key.fileobj, mask)
        except KeyboardInterrupt:
            print("[client] Shutting down...")
        finally:
            self.selector.close()

    def accept(self, sock, mask):
        browser_sock, addr = sock.accept()
        print(f"[client] Browser connection from {addr}")
        browser_sock.setblocking(True)

        # Parse minimal HTTP CONNECT request line
        request_line = self._read_line(browser_sock)
        if not request_line:
            browser_sock.close()
            return

        try:
            method, target, _ = request_line.decode().split(" ", 2)
        except ValueError:
            print(f"[client] Malformed request line: {request_line!r}")
            browser_sock.close()
            return

        if method.upper() != "CONNECT" or ":" not in target:
            print(f"[client] Unsupported request: {request_line!r}")
            browser_sock.close()
            return

        host, port_str = target.split(":", 1)
        port = int(port_str)
        print(f"[client] Request to {host}:{port}")

        # Discard headers until blank line
        while True:
            h = self._read_line(browser_sock)
            if not h or h in (b"\r\n", b"\n"):
                break

        # Open TCP connection to Python VPN server and wrap with TLS
        try:
            raw_sock = socket.create_connection((self.server_host, self.server_port))
            tls_sock = self.context.wrap_socket(raw_sock, server_hostname=self.server_hostname)
        except OSError as e:
            print(f"[client] Failed to connect to tunnel server: {e}")
            browser_sock.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
            browser_sock.close()
            return

        # Send header "host:port\n" over the TLS tunnel
        header = f"{host}:{port}\n".encode()
        tls_sock.sendall(header)

        browser_sock.setblocking(False)
        tls_sock.setblocking(False)

        # Send success response to browser
        browser_sock.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")

        # Relay in both directions
        self.selector.register(browser_sock, selectors.EVENT_READ, lambda s, m: self.relay(s, tls_sock))
        self.selector.register(tls_sock, selectors.EVENT_READ, lambda s, m: self.relay(s, browser_sock))

    def _read_line(self, sock):
        data = b""
        while True:
            chunk = sock.recv(1)
            if not chunk:
                break
            data += chunk
            if chunk == b"\n":
                break
        return data

    def relay(self, src, dst):
        try:
            data = src.recv(BUFFER_SIZE)
        except OSError:
            data = b""
        if not data:
            self._close_pair(src, dst)
            return
        try:
            dst.sendall(data)
        except OSError:
            self._close_pair(src, dst)

    def _close_pair(self, a, b):
        for sock in (a, b):
            try:
                self.selector.unregister(sock)
            except Exception:
                pass
            try:
                sock.close()
            except Exception:
                pass

def main():
    parser = argparse.ArgumentParser(description="Python DIY VPN-style client (HTTP CONNECT proxy)")
    parser.add_argument("--listen-host", default="127.0.0.1", help="Local proxy host (default: 127.0.0.1)")
    parser.add_argument("--listen-port", type=int, default=8080, help="Local proxy port (default: 8080)")
    parser.add_argument("--server-host", required=True, help="Tunnel server host/IP")
    parser.add_argument("--server-port", type=int, default=8443, help="Tunnel server port (default: 8443)")
    parser.add_argument(
        "--server-hostname",
        default="localhost",
        help="Server hostname for TLS SNI (default: localhost)",
    )
    args = parser.parse_args()

    client = ProxyClient(
        listen_host=args.listen_host,
        listen_port=args.listen_port,
        server_host=args.server_host,
        server_port=args.server_port,
        server_hostname=args.server_hostname,
    )
    client.start()

if __name__ == "__main__":
    main()
    

Start the client on your local machine:

BASH

python vpn_client.py --server-host YOUR_SERVER_IP --server-port 8443 --server-hostname localhost
    

7. Step 4 – Point your browser at the Python tunnel

Now make your browser use the client as an HTTP proxy:

  • Proxy host: 127.0.0.1
  • Proxy port: 8080 (or whatever you chose with --listen-port).
  • Proxy type: HTTP (not SOCKS).

Once configured:

  • Every HTTPS site you visit is tunnelled via your Python client → Python server → internet.
  • The server’s public IP is what sites will see as the origin of your traffic.
  • Your local network only sees encrypted TLS traffic between client and server.
Quick verification tips

To confirm it’s working:

  • Visit a “What is my IP” page before and after enabling the proxy—the IP should change to your server’s.
  • Stop the vpn_client.py script; your browser should lose connectivity until you disable the proxy.
  • Watch the terminal logs for [server] and [client] messages as requests flow.

8. Step 5 – Change your apparent location with multiple exit nodes

Websites usually estimate your “location” using IP geolocation databases. If you run:

  • One Python VPN server on a home machine in Brisbane, and
  • Another on a small cloud VM in Frankfurt, and
  • A third in a US region,

…then switching which Python server the client connects to will make you appear (to most sites) as if you moved between Australia, Germany, and the US. The mechanics are simple:

  1. Run vpn_server.py on each machine (each with its own server.crt/server.key).
  2. Expose a TCP port (for example 8443) on each one.
  3. On your laptop, choose which server to point vpn_client.py at.

8.1 Manually switching locations with CLI flags

The quickest way to change exit location is to start the client with different --server-host values:

BASH

# Exit in Australia (Sydney server)
python vpn_client.py --server-host syd-vpn.example.com --server-port 8443 --server-hostname syd-vpn

# Exit in Germany (Frankfurt server)
python vpn_client.py --server-host de-vpn.example.com --server-port 8443 --server-hostname de-vpn

# Exit in US East
python vpn_client.py --server-host us-vpn.example.com --server-port 8443 --server-hostname us-vpn
    

Each server has its own public IP. IP lookup sites will use that IP to infer that you’re in Sydney, Frankfurt or the US, respectively.

8.2 Using a small JSON profile file for locations

For a smoother workflow, you can define locations in a JSON config and choose them by name. Create vpn_locations.json next to your client script:

JSON

{
  "home-au": {
    "host": "203.0.113.10",
    "port": 8443,
    "hostname": "home-au"
  },
  "de-frankfurt": {
    "host": "198.51.100.20",
    "port": 8443,
    "hostname": "de-frankfurt"
  },
  "us-east": {
    "host": "203.0.113.30",
    "port": 8443,
    "hostname": "us-east"
  }
}
    

Now add a small wrapper script that reads this profile and launches the proxy with the right server:

PYTHON

# vpn_client_profiled.py
import argparse
import json
from vpn_client import ProxyClient  # assumes vpn_client.py is in the same folder

def main():
    parser = argparse.ArgumentParser(description="Profiled DIY VPN client")
    parser.add_argument("--profile", required=True, help="Location profile name (e.g. home-au, de-frankfurt)")
    parser.add_argument("--listen-host", default="127.0.0.1")
    parser.add_argument("--listen-port", type=int, default=8080)
    parser.add_argument("--profiles-file", default="vpn_locations.json")
    args = parser.parse_args()

    with open(args.profiles_file, "r", encoding="utf-8") as f:
        profiles = json.load(f)

    if args.profile not in profiles:
        raise SystemExit(f"Profile {args.profile!r} not found in {args.profiles_file}")

    p = profiles[args.profile]
    server_host = p["host"]
    server_port = p.get("port", 8443)
    server_hostname = p.get("hostname", server_host)

    print(f"[profiles] Using profile {args.profile} → {server_host}:{server_port}")

    client = ProxyClient(
        listen_host=args.listen_host,
        listen_port=args.listen_port,
        server_host=server_host,
        server_port=server_port,
        server_hostname=server_hostname,
    )
    client.start()

if __name__ == "__main__":
    main()
    

Now you can switch VPN “locations” by profile name instead of remembering hostnames:

BASH

# Appear in Australia
python vpn_client_profiled.py --profile home-au

# Appear in Germany
python vpn_client_profiled.py --profile de-frankfurt

# Appear in US East
python vpn_client_profiled.py --profile us-east
    

Your browser settings stay the same (proxy to 127.0.0.1:8080), but your apparent location changes every time you restart the client with a different profile.

9. Hardening and limitations

This Python DIY VPN-style tunnel is intentionally simple. Before relying on it for anything sensitive:

  • Enable real certificate verification. Instead of CERT_NONE, ship your own CA or pin the server certificate and configure verify_mode = CERT_REQUIRED with a trusted store.
  • Add authentication. Right now, anyone who can reach your server could use it as an open proxy. Add a shared secret in the initial header, or wrap the TLS layer in a higher-level auth protocol.
  • Log and rate-limit. Add logging around request frequency and IPs, plus rate limits to prevent abuse.
  • Firewall your server. Only allow inbound traffic on the tunnel port, and restrict other services.
  • Performance. Python can move a lot of data, but this will not match the throughput of a kernel-level VPN.

10. Copy-paste: full DIY VPN quick-start

Copyable checklist – Python DIY VPN from scratch

Use this condensed checklist when you want to spin up the tunnel again quickly or add a new location.

CHECKLIST

1. Ensure Python 3.9+ is installed on both client and server.

2. On each server (one per exit location):
   - python -m pip install cryptography
   - Save generate_cert.py and run:
       python generate_cert.py
     This creates server.crt and server.key.

3. On each server:
   - Save vpn_server.py next to server.crt and server.key.
   - Start the server:
       python vpn_server.py --host 0.0.0.0 --port 8443
   - Open TCP port 8443 in your router / firewall.

4. On the client:
   - Save vpn_client.py.
   - Start the client pointing at a specific server:
       python vpn_client.py --server-host SERVER_IP --server-port 8443 --server-hostname localhost

5. Configure your browser:
   - HTTP proxy: 127.0.0.1
   - Port: 8080 (or your chosen --listen-port)

6. Test:
   - Visit an IP-check page before/after enabling proxy.
   - Check console logs from vpn_client.py and vpn_server.py.
   - Disable the proxy or stop the client to revert.

7. To support multiple locations:
   - Create vpn_locations.json with host/port for each region.
   - Save vpn_client_profiled.py (wrapper around ProxyClient).
   - Start the client with:
       python vpn_client_profiled.py --profile de-frankfurt
     to switch where your traffic appears to originate.

8. Harden (recommended):
   - Replace CERT_NONE with proper certificate verification.
   - Add authentication (shared secret or token).
   - Apply firewall rules and rate limiting on the server.
      

11. References and further reading

If you want to go beyond this learning-grade tunnel into production-grade VPN design, these references are a good next step: