Skip to content

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

  1. Your machine sends a packet to 172.20.0.10:5432 (the database).
  2. WireGuard client encrypts it and sends it to the VPS on UDP port 51820.
  3. The wg-easy container receives the packet on its wg0 interface.
  4. The kernel forwards it to eth1 (the Docker network where the database lives).
  5. MASQUERADE rewrites the source IP from 10.8.0.2 (your VPN IP) to 172.20.0.2 (wg-easy's IP on that network).
  6. The database receives the packet, sees it came from 172.20.0.2, and replies to that address.
  7. The reply reaches wg-easy, which undoes the masquerade (restores destination to 10.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 MASQUERADE

PostUp / PostDown Explained

PartMeaning
iptablesLinux firewall/networking tool
-t natUse the NAT (Network Address Translation) table
-A POSTROUTINGAdd a rule that runs just before a packet leaves the container
-D POSTROUTINGDelete the same rule (cleanup on shutdown)
-s 10.8.0.0/24Only apply to packets from the VPN subnet (10.8.0.1–10.8.0.254)
-j MASQUERADERewrite 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 marks

Docker 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 services

All networks are external: true (created outside this compose file).

Adding a New Network

To give VPN access to a new network (e.g., production):

  1. Add the network to wireguard.yml under services.wg-easy.networks and under the top-level networks key.
  2. Redeploy the wg-easy service.
  3. No iptables changes needed — the masquerade rule already covers all interfaces.

Ports

PortProtocolPurpose
51820UDPWireGuard tunnel (must be open on VPS firewall)
51821TCPwg-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 varDev (default)Prod
INFRA_NETWORK_NAMEdaramex_monorepo_dev_networkdaramex_monorepo_network
Docker network subnet (created manually on VPS)172.20.0.0/16172.30.0.0/16
DB_STATIC_IP172.20.0.10172.30.0.10
REDIS_STATIC_IP172.20.0.11172.30.0.11
QDRANT_STATIC_IP172.20.0.12172.30.0.12
WG_DEFAULT_ADDRESS10.8.0.x10.9.0.x
WG_CLIENT_SUBNET10.8.0.0/2410.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_network

Then 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 /16 pointing 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.)

  1. Check VPN handshake — On your machine run wg show. If latest handshake keeps growing without resetting, the tunnel is dead. Reconnect.

  2. Check containers are runningdocker ps --filter name=database --filter name=wg-easy

  3. Verify masquerade rules existdocker exec wg-easy iptables -t nat -L POSTROUTING -n -v. You should see a MASQUERADE rule for 10.8.0.0/24.

  4. Test internal connectivitydocker exec wg-easy ping 172.20.0.10. If this works but your local ping doesn't, the masquerade rule is missing or wrong.

  5. Check routing inside wg-easydocker exec wg-easy ip route. Confirm there's a route for the target subnet (e.g., 172.20.0.0/16 dev eth1).

  6. UDP port blocked — Verify your VPS firewall allows inbound UDP 51820. Hosting providers sometimes reset firewall rules.

  7. Client AllowedIPs — Your WireGuard client config must include the Docker subnet in AllowedIPs (e.g., 0.0.0.0/0 for full tunnel, or 172.20.0.0/16 for split tunnel).