Two things bother me about a rented VPN. The exit node belongs to someone else, so every request I make lands in somebody else’s log file. And the ad blocking, where it exists at all, stops at the browser. I already pay for a VPS, so the tunnel may as well be mine: one I can watch, and one that filters ads for every app on the phone rather than just the browser tab.
The awkward part is the network I use it from. Only TCP 80 and 443 get out. WireGuard is UDP on a port of its own choosing, so it dies at the first hop, which means the tunnel has to look exactly like ordinary web traffic. This is the shape of what I ended up with.
restricted network (only 80/443 get out)
|
| TLS :443
v
+-------------------------------------------+
| nginx |
| location = /ws-<secret, per device> |
| logs: time, real IP, duration, bytes |
+-------------------------------------------+
| 127.0.0.1:10501 phone
| 127.0.0.1:10502 laptop
v
+-------------------------------------------+
| sing-box VLESS, no TLS, localhost only |
| route: DNS -> hijack |
| clash api on 127.0.0.1:9090 |
+-------------------------------------------+
| |
| :5353 | everything else
v v
+------------------+ the internet
| AdGuard Home |
| blocklists |
| upstream = DoT |
+------------------+
collector -> nginx log + clash api -> MariaDB -> dashboard What each piece is doing
nginxalready owns 443 for my websites. It terminates TLS, and one exact-match location per device proxies the WebSocket upgrade to a local port.sing-boxspeaks VLESS over that WebSocket. It listens on127.0.0.1only, with no TLS of its own, because nginx has already done that part.AdGuard Homeis the DNS for the tunnel and for nothing else: bound to127.0.0.1:5353, not a system resolver, not reachable from the network.- A small collector reads two sources that each see half the picture, and writes them into MariaDB for a dashboard.
Why WebSocket on 443 and not WireGuard
WireGuard is faster and far simpler to set up. It is also a UDP flow on a non-standard port with a fixed handshake, which is the easiest thing in the world for a captive network to drop. A VLESS stream inside a WebSocket upgrade, behind a real certificate on 443, is just an HTTPS connection: same port, same handshake, same certificate authority as the websites on the same host. Someone who opens the hostname in a browser gets a 301 to my main site, because the catch-all location is a plain redirect.
The price is throughput. You are running TCP inside TCP, so a lossy link recovers twice and feels it. For browsing, chat, and everything a phone does all day, I have never noticed. For a big download, I have.
Step 1 · sing-box, listening on localhost only
One inbound per device. Each gets its own port, its own UUID, and its own WebSocket path, which is what makes per-device monitoring possible later. Note there is no tls block anywhere: nginx is the only thing holding a certificate.
{
"log": { "level": "warn", "timestamp": true },
"dns": {
"servers": [
{
"type": "udp",
"tag": "adguard",
"server": "127.0.0.1",
"server_port": 5353
}
],
"final": "adguard",
"strategy": "prefer_ipv4"
},
"inbounds": [
{
"type": "vless",
"tag": "phone",
"listen": "127.0.0.1",
"listen_port": 10501,
"users": [{ "uuid": "PASTE-A-UUID", "name": "phone" }],
"transport": { "type": "ws", "path": "/ws-PASTE-A-RANDOM-HEX" }
},
{
"type": "vless",
"tag": "laptop",
"listen": "127.0.0.1",
"listen_port": 10502,
"users": [{ "uuid": "PASTE-ANOTHER-UUID", "name": "laptop" }],
"transport": { "type": "ws", "path": "/ws-PASTE-ANOTHER-HEX" }
}
],
"outbounds": [{ "type": "direct", "tag": "direct" }],
"route": {
"rules": [
{ "action": "sniff" },
{ "protocol": "dns", "action": "hijack-dns" }
],
"final": "direct"
},
"experimental": {
"clash_api": {
"external_controller": "127.0.0.1:9090",
"secret": "PASTE-A-LONG-RANDOM-STRING"
},
"cache_file": { "enabled": true }
}
} Generate the secrets, never invent them by hand: uuidgen for the users, openssl rand -hex 16 for each path, openssl rand -hex 24 for the Clash API. The hijack-dns route rule is the line that makes the ad blocking unavoidable later, so keep it even before AdGuard exists.
This is written for sing-box 1.12 and newer, where DNS servers are typed objects and sniffing moved from the inbound into a route action. On 1.11 and older the same config has a different shape.
Step 2 · nginx as the front door
The upgrade map goes in the http context once. Everything else lives in the vhost, and each device gets its own exact-match location. An exact match beats every prefix match, so the catch-all redirect at the bottom never touches the tunnel.
# http context, once
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
log_format vpnsess escape=json
'{"t":"$time_iso8601","path":"$uri","ip":"$remote_addr",'
'"dur":$request_time,"in":$request_length,"out":$bytes_sent}';
server {
listen 443 ssl http2;
server_name tunnel.example.com;
ssl_certificate /etc/letsencrypt/live/tunnel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tunnel.example.com/privkey.pem;
# one block per device (I generate these from a devices file)
location = /ws-THE-PHONES-HEX {
if ($http_upgrade !~* "^websocket$") { return 404; }
access_log /var/log/vpn/sessions.log vpnsess;
proxy_pass http://127.0.0.1:10501;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# everyone else gets an ordinary looking redirect
location / { return 301 https://example.com$request_uri; }
} The if line matters more than it looks. Without it, a plain GET to a guessed path returns something that confirms the path exists. With it, anything that is not a WebSocket upgrade gets a 404, the same answer as a path that was never there. The long read and send timeouts stop nginx from tearing down an idle tunnel every 60 seconds, and proxy_buffering off keeps latency down.
On the client, any VLESS client will do (sing-box, v2rayN, Hiddify, NekoBox). It is four values plus the certificate.
{
"type": "vless",
"tag": "home",
"server": "tunnel.example.com",
"server_port": 443,
"uuid": "THE-UUID-FOR-THIS-DEVICE",
"tls": { "enabled": true, "server_name": "tunnel.example.com" },
"transport": { "type": "ws", "path": "/ws-THIS-DEVICES-HEX" }
} Step 3 · Do not let nginx log the secret
This one cost me a while to notice. The path is the thing that authorises the tunnel, and at nginx’s default error level every dropped WebSocket writes a line like recv() failed ... GET /ws-<secret> into a plaintext file, together with the client’s real IP. Phones drop connections constantly, so within a day that file is both a browsing-time trail and a leak of the one value you did not want written down.
# server level: no general access log, and only real faults in the error log
access_log off;
error_log /var/log/nginx/tunnel-error.log crit; At crit a broken certificate or a dead upstream is still recorded, which is all I ever needed the error log for. The per-device access_log ... vpnsess inside each location survives, because that one is deliberate and I control its format.
Step 4 · Ads filtered at DNS, inside the tunnel only
AdGuard Home is the ad blocker, but it is not the system resolver and it is not on the network. It binds to loopback, and the only thing that can reach it is sing-box, which hijacks port 53 for everything inside the tunnel. That detail is what makes it work on a phone: an app that hardcodes 8.8.8.8 to dodge your DNS settings still ends up at AdGuard.
# AdGuardHome.yaml, the parts that matter
bind_host: 127.0.0.1 # admin UI, reach it over an SSH tunnel
bind_port: 3000
dns:
bind_hosts:
- 127.0.0.1 # never 0.0.0.0, or you have an open resolver
port: 5353 # unprivileged, so the service needs no capabilities
upstream_dns:
- tls://dns.quad9.net
- tls://one.one.one.one
bootstrap_dns:
- 9.9.9.9
- 1.1.1.1
upstream_mode: load_balance
querylog:
enabled: false # see the note at the end
statistics:
enabled: true
interval: 24h Two settings carry most of the weight. Binding to 127.0.0.1 means you have not accidentally published an open resolver for the internet to amplify attacks through. Setting the upstream to DoT means the network I am sitting on sees an encrypted tunnel and nothing else, not even the names I am resolving inside it.
For blocklists, the defaults plus AdGuard DNS filter and OISD get rid of nearly everything without breaking sites. Add lists slowly. Every aggressive list eventually blocks a payment page at the worst possible moment.
Step 5 · Monitoring, assembled from two halves
Neither side sees the whole picture, and I have come to think of that as a feature rather than a limitation.
- nginx sees the client. Real IP, when the session opened, how long it lasted, bytes in and out, and which device (the path identifies it). It cannot see one byte inside the tunnel.
- The Clash API sees the destinations. Hostname, inbound tag (so, again, which device), upload and download per connection. It never sees the client’s real IP, because as far as sing-box is concerned the client is nginx on localhost.
The Clash API is already switched on in the config above. It is a plain HTTP endpoint, bearer token, no client needed:
SECRET=$(cat /etc/sing-box/clash-secret)
curl -s -H "Authorization: Bearer $SECRET" \
http://127.0.0.1:9090/connections |
jq '.connections[] | {
device: .metadata.inboundTag,
host: .metadata.host,
up: .upload,
down: .download
}' A small PHP process under systemd loops every few seconds: sample the Clash API, ingest whatever is new in the session log, geolocate each IP once and cache it, and write a per-minute traffic sample so the charts have history. Three tables are enough.
CREATE TABLE vpn_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device VARCHAR(64) NOT NULL,
ip VARCHAR(45) NOT NULL,
country VARCHAR(64) NULL,
city VARCHAR(64) NULL,
started DATETIME NOT NULL,
duration DECIMAL(10,3) NOT NULL,
bytes_in BIGINT UNSIGNED NOT NULL,
bytes_out BIGINT UNSIGNED NOT NULL,
KEY device_started (device, started)
);
CREATE TABLE vpn_visits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
device VARCHAR(64) NOT NULL,
host VARCHAR(255) NOT NULL,
seen_at DATETIME NOT NULL,
hits INT UNSIGNED NOT NULL DEFAULT 1,
UNIQUE KEY device_host_seen (device, host, seen_at)
);
CREATE TABLE traffic_history (
sampled_at DATETIME PRIMARY KEY,
up BIGINT UNSIGNED NOT NULL,
down BIGINT UNSIGNED NOT NULL
); The collector reads the log by byte offset and stores that offset in a small state table, so a restart resumes where it stopped instead of replaying the file. Rotate the session log daily and the whole thing stays small forever.
The dashboard on top of this is deliberately boring: who is online now, traffic today per device, the last sessions with a city next to each IP, and the destinations each device has been talking to. That is the entire reason for building it instead of renting it.
Step 6 · The services should not be able to do anything
The packaged sing-box unit grants CAP_NET_ADMIN, CAP_NET_RAW, CAP_SYS_PTRACE and CAP_DAC_READ_SEARCH, because the same binary can also run TUN, tproxy, and redirect inbounds. This deployment does none of that. A VLESS inbound bound to loopback behind nginx needs no capabilities at all, so take them away in a drop-in rather than editing the packaged unit.
# /etc/systemd/system/sing-box.service.d/hardening.conf
[Service]
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectProc=invisible
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0077 AdGuard gets the same treatment with ReadWritePaths pointed at its work directory, which is the only place it needs to write. Port 5353 being unprivileged is exactly why it can run with nothing. Then run systemd-analyze security sing-box.service and keep going until the number stops embarrassing you.
What this gives you, and what it does not
- It gives you the exit node. The logs are yours, the retention is yours, and nobody is selling the browsing history of the household.
- It gives you ad blocking everywhere, including inside apps that have never heard of your browser extension, because the filtering happens before the connection is made rather than after the page loads.
- It does not make you anonymous. The VPS is rented in your name and pays with your card. It moves your traffic out of a network you do not trust into one you do, which is a different and much more achievable goal.
- It does not beat serious traffic analysis. Looking like HTTPS on 443 is enough for an ordinary filtered network, not for an adversary who measures timing and volume.
One last thing, and it is the part I would think hardest about. You are now the person holding the logs. I keep sessions and destination hostnames because the tunnel serves my own devices and I want to notice when something on the network starts talking to a place it should not. I keep AdGuard’s query log switched off, because its aggregate counters already tell me the blocklists are working, and a full per-query history is a thing I would rather simply not have. Log the metadata you would be comfortable with someone else holding about you, and rotate the per-device paths whenever a device leaves your hands.