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.
In this guide, “DIY VPN” means:
- We build an encrypted TCP tunnel between a client and a server using Python’s
socketandsslmodules. - 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
sslwrapper. - 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).
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:
- Browser asks client for a connection to
example.com:443using the HTTPCONNECTmethod. - Client opens a TLS socket to the Python server and tells it
example.com:443. - Server connects to
example.com:443from wherever it’s running. - 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:
socketandselectorsfor non-blocking I/O.sslfor TLS encryption.- The
cryptographylibrary 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.
# On both client and server machines, install cryptography once:
python -m pip install cryptography
Save this as generate_cert.py on the server:
# 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:
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:
# 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:
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
CONNECTrequests from your browser. - Open a TLS connection to the tunnel server.
- Send the target
host:portin a header line. - Relay bytes between browser and server.
Save this as vpn_client.py:
# 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:
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.
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.pyscript; 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:
- Run
vpn_server.pyon each machine (each with its ownserver.crt/server.key). - Expose a TCP port (for example
8443) on each one. - On your laptop, choose which server to point
vpn_client.pyat.
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:
# 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:
{
"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:
# 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:
# 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 configureverify_mode = CERT_REQUIREDwith 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
Use this condensed checklist when you want to spin up the tunnel again quickly or add a new location.
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:
-
Python docs –
socket: https://docs.python.org/3/library/socket.html -
Python docs –
ssl: https://docs.python.org/3/library/ssl.html -
Python docs –
selectors: https://docs.python.org/3/library/selectors.html - Cryptography library documentation (for certificate generation): https://cryptography.io/en/latest/
- OpenVPN project (for comparison with a fully featured VPN): https://openvpn.net/community/
- WireGuard project (another modern VPN protocol, useful for architectural comparison): https://www.wireguard.com/
- General introduction to VPN concepts (tunnels, encryption, and IP-based location): https://en.wikipedia.org/wiki/Virtual_private_network
- Overview of IP geolocation (how sites infer your “location” from your IP): https://en.wikipedia.org/wiki/Internet_geolocation