Appearance
VPN — WireGuard (wg-easy)
We use wg-easy as a WireGuard VPN gateway so that developers can reach internal Docker services (database, Redis, Qdrant, etc.) without exposing their ports to the internet.
Network Topology
Developer Machine VPS (Dokploy)
┌──────────────┐ ┌──────────────────────────────────────┐
│ │ UDP 51820 │ │
│ WireGuard │◄─────────────────────►│ wg-easy container │
│ Client │ encrypted tunnel │ ┌─────────┐ │
│ 10.8.0.2 │ │ │ wg0 │ 10.8.0.1 │
│ │ │ │ eth0 │ 172.21.x.x (dokploy) │
└──────────────┘ │ │ eth1 │ 172.20.x.x (infra) │
│ └────┬─────┘ │
│ │ MASQUERADE │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ daramex_monorepo_dev_network│ │
│ │ database 172.20.0.10 │ │
│ │ redis 172.20.0.11 │ │
│ │ qdrant (dynamic IP) │ │
│ └─────────────────────────────┘ │
└──────────────────────────────────────┘How Traffic Flows
- Your machine sends a packet to
172.20.0.10:5432(the database). - WireGuard client encrypts it and sends it to the VPS on UDP port
51820. - The
wg-easycontainer receives the packet on itswg0interface. - The kernel forwards it to
eth1(the Docker network where the database lives). - MASQUERADE rewrites the source IP from
10.8.0.2(your VPN IP) to172.20.0.2(wg-easy's IP on that network). - The database receives the packet, sees it came from
172.20.0.2, and replies to that address. - The reply reaches
wg-easy, which undoes the masquerade (restores destination to10.8.0.2) and sends it back through the tunnel.
Why Masquerade Is Necessary
Without masquerade, the database receives a packet from 10.8.0.2. It has no route to 10.8.0.0/24 (the VPN subnet) — that network only exists inside the wg-easy container. The database drops the packet and your connection times out.
Masquerade solves this by making all VPN traffic appear to come from wg-easy itself, which is on the same Docker network as the database.
Key Configuration (wireguard.yml)
yaml
environment:
- WG_HOST=${WG_HOST} # Server public IP or domain
- PASSWORD_HASH=${WG_PASSWORD_HASH} # Admin UI password (bcrypt)
- WG_PORT=51820 # WireGuard listen port
- WG_DEFAULT_DNS=1.1.1.1 # DNS for VPN clients
- WG_POST_UP=iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -j MASQUERADE
- WG_POST_DOWN=iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -j MASQUERADEPostUp / PostDown Explained
| Part | Meaning |
|---|---|
iptables | Linux firewall/networking tool |
-t nat | Use the NAT (Network Address Translation) table |
-A POSTROUTING | Add a rule that runs just before a packet leaves the container |
-D POSTROUTING | Delete the same rule (cleanup on shutdown) |
-s 10.8.0.0/24 | Only apply to packets from the VPN subnet (10.8.0.1–10.8.0.254) |
-j MASQUERADE | Rewrite the source IP to match the outgoing interface's IP |
WG_POST_UP runs when WireGuard starts (adds the rule). WG_POST_DOWN runs when WireGuard stops (removes the rule to leave things clean).
No -o ethX is specified, so the rule applies to all interfaces. This means it works for any Docker network the container is connected to — no changes needed when adding new networks (e.g., a future daramex_monorepo_prod_network).
Required Capabilities
yaml
cap_add:
- NET_ADMIN # Allows iptables and network interface management
- SYS_MODULE # Allows loading the WireGuard kernel module
sysctls:
- net.ipv4.ip_forward=1 # Enable packet forwarding between interfaces
- net.ipv4.conf.all.src_valid_mark=1 # Required for WireGuard routing marksDocker Networks
The wg-easy container must be connected to every Docker network it needs to route traffic to:
yaml
networks:
- daramex_monorepo_dev_network # Infrastructure services
- dokploy-network # Dokploy management + app servicesAll networks are external: true (created outside this compose file).
Adding a New Network
To give VPN access to a new network (e.g., production):
- Add the network to
wireguard.ymlunderservices.wg-easy.networksand under the top-levelnetworkskey. - Redeploy the wg-easy service.
- No iptables changes needed — the masquerade rule already covers all interfaces.
Ports
| Port | Protocol | Purpose |
|---|---|---|
51820 | UDP | WireGuard tunnel (must be open on VPS firewall) |
51821 | TCP | wg-easy management UI (accessible via VPN only, not exposed publicly) |
Infrastructure Services (Fixed IPs)
Defined in docker-compose-infra.yml on the infra network. The Docker network name, static IPs, and VPN client subnet are all parametrized via env vars (set per-environment in Dokploy's Environment tab) so dev and prod can run side-by-side with active VPN tunnels to both.
Environment matrix
| Env var | Dev (default) | Prod |
|---|---|---|
INFRA_NETWORK_NAME | daramex_monorepo_dev_network | daramex_monorepo_network |
| Docker network subnet (created manually on VPS) | 172.20.0.0/16 | 172.30.0.0/16 |
DB_STATIC_IP | 172.20.0.10 | 172.30.0.10 |
REDIS_STATIC_IP | 172.20.0.11 | 172.30.0.11 |
QDRANT_STATIC_IP | 172.20.0.12 | 172.30.0.12 |
WG_DEFAULT_ADDRESS | 10.8.0.x | 10.9.0.x |
WG_CLIENT_SUBNET | 10.8.0.0/24 | 10.9.0.0/24 |
Provisioning a new VPS
Before the first deploy, create the external Docker network with the right subnet. This is a one-time step, Dokploy will not create it for you:
bash
# On dev VPS (already done, do not re-run)
docker network create --subnet=172.20.0.0/16 daramex_monorepo_dev_network
# On prod VPS (one-time, before first deploy)
docker network create --subnet=172.30.0.0/16 daramex_monorepo_networkThen set the matching env vars above in Dokploy's Environment tab for each of the 4 compose-apps (infra, api, observability, wireguard) in the corresponding Project.
Why both routing layers are parametrized
Two independent subnets need to stay distinct per environment so your laptop can have both WireGuard tunnels active at once:
- Docker bridge subnet (
172.20.x/172.30.x) — where postgres, redis, qdrant live. Your local routing table cannot have two routes for the same/16pointing to different WireGuard peers. - VPN client subnet (
10.8.x/10.9.x) — the IP range wg-easy hands out to connected clients. Same collision rule.
Use the IPs from your VPN client directly (e.g., DBeaver → 172.20.0.10:5432 for dev, 172.30.0.10:5432 for prod).
Troubleshooting
Connection timed out (DBeaver, ping, etc.)
Check VPN handshake — On your machine run
wg show. Iflatest handshakekeeps growing without resetting, the tunnel is dead. Reconnect.Check containers are running —
docker ps --filter name=database --filter name=wg-easyVerify masquerade rules exist —
docker exec wg-easy iptables -t nat -L POSTROUTING -n -v. You should see aMASQUERADErule for10.8.0.0/24.Test internal connectivity —
docker exec wg-easy ping 172.20.0.10. If this works but your local ping doesn't, the masquerade rule is missing or wrong.Check routing inside wg-easy —
docker exec wg-easy ip route. Confirm there's a route for the target subnet (e.g.,172.20.0.0/16 dev eth1).UDP port blocked — Verify your VPS firewall allows inbound UDP 51820. Hosting providers sometimes reset firewall rules.
Client AllowedIPs — Your WireGuard client config must include the Docker subnet in
AllowedIPs(e.g.,0.0.0.0/0for full tunnel, or172.20.0.0/16for split tunnel).