$ ./suraj --boot
[ ok ] loading core modules
[ ok ] mounting filesystem
[ ok ] initializing runtime
[ ok ] resolving routes
[ ok ] starting interface
$ ready
~/suraj
// 2025.06.30 · 9 min read

A private API over WireGuard: why the network boundary is the security control, not the firewall rule

Run an API on one VPS, reach it from another only through an encrypted tunnel, and never open its port to anyone. A short, practical WireGuard setup — with the two firewall layers that trip everyone up, the pitfalls that actually bite, and a debugging method that converges.

Most services that get breached were never meant to face the public internet. They got exposed because exposure was the easy path — bind to 0.0.0.0, open a port, “add the allowlist later.” The allowlist is the part that gets forgotten, or quietly widened during an incident at 2am.

There’s a stronger default: don’t put the service on the public internet at all. Run the API on one VPS, reach it from another only through an encrypted tunnel, and never open its port to anyone. From the outside the service doesn’t exist — there’s no port to scan, no auth endpoint to brute-force, nothing to probe. The network boundary is the security control.

This is the short, practical setup for that pattern with WireGuard: one VPS running an API, a second calling it across a private tunnel.

I’m using Oracle Cloud for this demo, so the cloud-firewall steps below are Oracle-specific. The shape is identical on any provider — swap the VCN security-list step for your provider’s equivalent (AWS security groups, GCP firewall rules, DigitalOcean cloud firewalls).

The mental model that prevents most of the pain

There are two layers of packet, filtered in different places. Keep them separate in your head and most bugs become obvious:

  1. The outer packet — encrypted WireGuard, UDP on port 51820, travelling over the public internet. This is what cloud firewalls see.
  2. The inner packet — your actual API traffic (TCP to port 3000), which only exists inside the tunnel, on the wg0 interface. The public internet never sees it.

Almost every “it just hangs” bug comes from debugging the wrong layer.

The setup

Public IPWireGuard IPRole
Server203.0.113.110.66.66.1WG listener + API
Client198.51.100.210.66.66.2WG peer, calls the API

Step 1 — Pick a tunnel subnet that doesn’t collide

Check what private subnet your provider already assigned:

ip addr show          # look at ens3 / eth0

If you see inet 10.0.0.191/16, your provider’s private network owns the entire 10.0.x.x range. Your WireGuard subnet must not overlap it — if it does, the kernel routes tunnel traffic out the public NIC and nothing works, even though the handshake still succeeds. Oracle commonly hands out 10.0.0.0/16, so avoid 10.0.0.x. 10.66.66.0/24 is safe. This is the single most consequential choice in the setup.

Step 2 — Install WireGuard (both VPS)

sudo apt update && sudo apt install -y wireguard

Step 3 — Generate keys (each VPS separately)

cd /etc/wireguard
umask 077
wg genkey | tee privatekey | wg pubkey > publickey

Each VPS keeps its own private key and puts the other’s public key in its [Peer] block. Cross them and the handshake silently never forms.

Security: the private key is the whole identity of the peer — treat it like a password. The umask 077 above keeps it readable only by root; never commit it to git, paste it into a config-management repo, or copy it between VPS. Only ever move the public key around. If a private key leaks, rotate both peers’ keys.

Step 4 — Write the configs

Server /etc/wireguard/wg0.conf:

[Interface]
Address = 10.66.66.1/24
ListenPort = 51820
PrivateKey = <SERVER_PRIVATE_KEY>

[Peer]
PublicKey = <CLIENT_PUBLIC_KEY>
AllowedIPs = 10.66.66.2/32

Client /etc/wireguard/wg0.conf:

[Interface]
Address = 10.66.66.2/24
PrivateKey = <CLIENT_PRIVATE_KEY>

[Peer]
PublicKey = <SERVER_PUBLIC_KEY>
Endpoint = 203.0.113.1:51820
AllowedIPs = 10.66.66.1/32
PersistentKeepalive = 25

Only the client has Endpoint (it dials out) and PersistentKeepalive (keeps the path alive through NAT). AllowedIPs is doing double duty — it’s both the key selector and the routing table for the tunnel.

Security: keep AllowedIPs as tight as the topology allows — /32 for a point-to-point link, not /24. It’s a cryptographic allowlist: a peer can only send packets whose source falls inside its AllowedIPs, so a narrow range means a compromised or misconfigured peer can’t impersonate the rest of the subnet. Widen it only when you actually add hosts.

Step 5 — Open the firewall (both layers)

5a. Cloud layer — on the server’s subnet

On Oracle: Console → Networking → your VCN → Security List (or the instance’s NSG) → Add Ingress Rule:

  • Source CIDR: 0.0.0.0/0 (or lock to the client’s public IP)
  • IP Protocol: UDP — the dropdown defaults to TCP and you must change it
  • Destination Port Range: 51820

For the testing purpose I’ve used Source CIDR: 0.0.0.0/0 , but it’s recommended to use the client’s public IP so the tunnel endpoint is completely protected — only your client VPS can even attempt a handshake.

On another provider, this is the same rule in your security group / firewall. You do not open the API port (3000) here, or anywhere, ever.

5b. OS layer — iptables on the server

Oracle’s Ubuntu images ship iptables rules directly — not ufw — ending in a catch-all REJECT. So ufw status reporting inactive does not mean the host is open. Inspect the real chain:

sudo iptables -L INPUT -n --line-numbers

If there’s a REJECT ... icmp-host-prohibited near the bottom, insert allow rules above it:

sudo iptables -I INPUT -p udp --dport 51820 -j ACCEPT
sudo iptables -I INPUT -i wg0 -j ACCEPT     # trust the tunnel interface
sudo netfilter-persistent save

Trusting wg0 wholesale is safe: only authenticated peers can put packets on it. The cryptography is the access control.

Step 6 — Bring up the tunnel (both VPS)

sudo wg-quick up wg0
sudo systemctl enable wg-quick@wg0     # survive reboot
sudo wg                                # want a handshake line + nonzero received

Step 7 — Bind the API to the tunnel IP, never 0.0.0.0

This one line is the whole security boundary.

const express = require('express');
const app = express();
const HOST = process.env.HOST || '10.66.66.1';   // tunnel IP, NOT 0.0.0.0
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.get('/api/health', (req, res) =>
  res.json({ status: 'ok', timestamp: new Date().toISOString() }));

app.listen(PORT, HOST, () => console.log(`Server on http://${HOST}:${PORT}`));

Confirm the bind — this is the proof the boundary holds:

ss -tlnp | grep 3000       # must show 10.66.66.1:3000, never 0.0.0.0:3000

If you run it under systemd, add After= / Requires=wg-quick@wg0.service so the API only starts once wg0 exists — otherwise it races the tunnel on boot and crashes with EADDRNOTAVAIL.

Security: binding to 10.66.66.1 instead of 0.0.0.0 is what makes the port physically unreachable from the public NIC — it’s the boundary, so verify it on every deploy rather than trusting it. Because the tunnel already authenticates and encrypts every caller, you can usually drop per-request API auth for internal-only services; if the data is sensitive, keep auth anyway as defence in depth.

Step 8 — Test from the client

curl http://10.66.66.1:3000/api/health

JSON back means you’re done. The same request from the public internet fails — no route, no open port, nothing to probe. That failure is the feature.

The pitfalls that actually bite

#SymptomCauseFix
1Handshake succeeds but curl hangs; ip route get <wg-ip> shows dev ens3Tunnel subnet collides with the cloud private subnetRenumber the tunnel to a non-overlapping range. The #1 killer.
2No handshake; wg shows bytes sent but 0 receivedCloud firewall dropping UDP 51820 — missing, set to TCP, or wrong subnetAdd a UDP 51820 ingress rule on the server’s subnet
3ufw status inactive, yet packets blockedImage filters via iptables directly, with a catch-all REJECTInspect iptables -L INPUT; insert ACCEPT above the REJECT
4Tunnel up but curl hangs (not “refused”)OS firewall drops the inner TCP arriving on wg0iptables -I INPUT -i wg0 -j ACCEPT
5Unreachable through tunnel; works on 0.0.0.0App bound to 0.0.0.0 (exposed) or 127.0.0.1 (can’t see tunnel)Bind to the tunnel IP; verify with ss -tlnp
6Handshake goes stale; traffic dies after idleClient behind NAT, no keepalivePersistentKeepalive = 25 on the client
7Packets reach server, no replyServer peer AllowedIPs missing the client’s tunnel IPSet it to the client’s IP (/32)
8Handshake never forms, keys look rightPublicKey mismatch — each side must hold the other’s keyCross-check wg show on A against [Peer] on B

A debugging method that converges

When it hangs, don’t guess — bisect the path:

  1. Is the app serving? Curl it locally on the server. Hangs → it’s the app. JSON → continue.
  2. Does the request enter the tunnel? sudo tcpdump -ni wg0 tcp port 3000, then curl from the client. Nothing → client isn’t routing in (Pitfall #1). SYN but no SYN-ACK → server can’t reply (#7 / #4).
  3. ip route get <target-ip> is the truth-teller. It must say dev wg0. If it says dev ens3 / eth0, you have a subnet collision — no firewall change will fix that.

The golden rule: a handshake succeeding does not prove the data path works. The handshake travels by Endpoint; your data travels by AllowedIPs and the routing table. Confirm both, separately.

Hardening checklist

A few recommendations worth applying before you call it production:

  • Lock the cloud ingress rule to the client’s public IP, not 0.0.0.0/0 — the tunnel is encrypted either way, but a scoped rule means only your client can even reach the WireGuard port.
  • Keep private keys root-only and out of version control; rotate both peers if one ever leaks.
  • Keep AllowedIPs and the API bind address as narrow as possible/32 peers, tunnel-IP binds, never 0.0.0.0.
  • Don’t open the API port anywhere — if you ever find yourself adding a rule for port 3000, you’ve broken the model.
  • Patch and reboot on a schedule; WireGuard rides the kernel, so a current kernel is part of its security.

Closing

This pattern is worth the extra setup not because WireGuard is fast (it is), but because it changes what an attacker can even attempt. A service on a public port with an allowlist is one misconfiguration away from exposure — and that misconfiguration will happen, on the day you can least afford it. A service that lives only on wg0 has no public attack surface to misconfigure.

Internal APIs, admin panels, database proxies, cross-region service calls — anything that has no business answering the public internet belongs behind a tunnel like this. Set it up once, keep the two-layer model in mind, and “is this endpoint exposed?” stops being a question you have to keep answering.