Secure Reverse Proxying Behind NAT Using WireGuard and Caddy

I’m migrating my existing reverse proxy and TLS termination setup from Pangolin to a simpler and more flexible architecture using Caddy and WireGuard.

Table of Contents

Prerequisite

  • Server with public IP
  • Local Server (NAT or CGNAT)
  • Domain from Cloudflare

VPS (Public IP)

Install Wireguard.

1sudo apt update
2sudo apt install wireguard resolvconf -y

Create private key, public key and remove unnecessary permissions from the keys.

1sudo wg genkey | tee /etc/wireguard/server_private_key | sudo wg pubkey > /etc/wireguard/server_public_key
2
3sudo chmod go= /etc/wireguard/server_private_key

Create Wireguard server configuration.

 1EXT_IF=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if ($i=="dev") print $(i+1)}')
 2
 3sudo cat > /etc/wireguard/wg0.conf <<EOF
 4# Server configuration
 5[Interface]
 6PrivateKey = $(cat /etc/wireguard/server_private_key)
 7Address = 10.0.0.1/24
 8
 9# PostUP - Commands to run after starting WireGuard
10PostUp = iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT
11PostUp = iptables -t nat -I POSTROUTING 1 -s 10.0.0.0/24 -o ${EXT_IF} -j MASQUERADE
12PostUp = iptables -I INPUT 1 -i wg0 -j ACCEPT
13PostUp = iptables -I FORWARD 1 -i ${EXT_IF} -o wg0 -j ACCEPT
14PostUp = iptables -I FORWARD 1 -i wg0 -o ${EXT_IF} -j ACCEPT
15PostUp = iptables -A FORWARD -i ${EXT_IF} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
16
17# Accept connections to WireGuard and HTTP/HTTPS ports
18PostUp = iptables -I INPUT 1 -i ${EXT_IF} -p udp --dport 51820 -j ACCEPT
19PostUp = iptables -I INPUT 1 -i ${EXT_IF} -p tcp --dport 80 -j ACCEPT
20PostUp = iptables -I INPUT 1 -i ${EXT_IF} -p tcp --dport 443 -j ACCEPT
21
22# PostDown - Commands to run after stopping WireGuard
23PostDown = iptables -D FORWARD -i wg0 -o wg0 -j ACCEPT
24PostDown = iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o ${EXT_IF} -j MASQUERADE
25PostDown = iptables -D INPUT -i wg0 -j ACCEPT
26PostDown = iptables -D FORWARD -i ${EXT_IF} -o wg0 -j ACCEPT
27PostDown = iptables -D FORWARD -i wg0 -o ${EXT_IF} -j ACCEPT
28PostDown = iptables -D FORWARD -i e${EXT_IF} -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
29
30PostDown = iptables -D INPUT -i ${EXT_IF} -p udp --dport 51820 -j ACCEPT
31PostDown = iptables -D INPUT -i ${EXT_IF} -p tcp --dport 80 -j ACCEPT
32PostDown = iptables -D INPUT -i ${EXT_IF} -p tcp --dport 443 -j ACCEPT
33
34# WireGuard port
35ListenPort = 51820
36
37# Client Configuration
38[Peer]
39PublicKey = $(cat /etc/wireguard/client_public_key)
40AllowedIPs = 10.0.0.2/32
41EOF

Enable IP forwarding and WireGuard service.

1# Enable IP forwarding
2sudo sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/sysctl.conf
3sudo sysctl -p
4# Enable WireGuard service
5sudo systemctl enable wg-quick@wg0.service
6sudo systemctl start wg-quick@wg0.service

Check Wireguard status.

1sudo wg show

Create peer config, later copy this to your local node.

 1mkdir /opt/wireguard/peer001
 2
 3sudo wg genkey | tee /opt/wireguard/peer001/client_private_key | sudo wg pubkey > /opt/wireguard/peer001/client_public_key
 4
 5sudo cat > /opt/wireguard/peer001/peer_wg0.conf <<EOF
 6[Interface]
 7PrivateKey = $(cat /opt/wireguard/peer001/client_private_key)
 8Address = 10.0.0.2/32
 9
10[Peer]
11PublicKey = $(cat /etc/wireguard/server_public_key)
12Endpoint = $(curl ifconfig.me):51820
13AllowedIPs = 10.0.0.0/8
14PersistentKeepalive = 25
15EOF

Optional, create QR for the configurations.

1qrencode -o /opt/wireguard/peer001/wireguard_qr.png < /opt/wireguard/peer001/peer_wg0.conf

Repeat this process if you want to create and add more peer. Make sure to create dir per config and change the peer IP address.

Also add the private key and IP address of new peer.

/etc/wireguard/wg0.conf

 1PostDown = iptables -D INPUT -i ${EXT_IF} -p udp --dport 51820 -j ACCEPT
 2PostDown = iptables -D INPUT -i ${EXT_IF} -p tcp --dport 80 -j ACCEPT
 3PostDown = iptables -D INPUT -i ${EXT_IF} -p tcp --dport 443 -j ACCEPT
 4
 5ListenPort = 51820
 6
 7[Peer]
 8PublicKey = $(cat /etc/wireguard/client_public_key)
 9AllowedIPs = 10.0.0.2/32
10
11# ADD HERE
12[Peer]
13PublicKey = NEW_CLIENT_PUBLIC_KEY
14AllowedIPs = 10.0.0.3/32

Wireguard Peer (Local Server)

Install wireguard.

1sudo apt update
2sudo apt install wireguard resolvconf -y

Enable IP forwarding and WireGuard service.

1sudo sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/sysctl.conf
2sudo sysctl -p

Copy Wireguard peer config. /etc/wireguard/wg0.conf

1[Interface]
2PrivateKey = 8DW1ba8GHfA9hKAXrm17ssWX0aXQ4Za2ozSsHN6c1Z0c=
3Address = 10.0.0.2/32
4
5[Peer]
6PublicKey = 6F8h1/L2xwYLXe32ffBA+97pjVDsPJ7/uFkAT/OMChM=
7Endpoint = <YOUR_PUBLIC_IP>:51820
8AllowedIPs = 10.0.0.0/24
9PersistentKeepalive = 25

Add this config, application you want to reverse proxy to vps server. A nginx server is running in 192.168.254.101:80

Final config.

 1[Interface]
 2PrivateKey = 8DW1ba8GHfA9hKAXrm17ssWX0aXQ4Za2ozSsHN6c1Z0c=
 3Address = 10.0.0.2/32
 4
 5# add this config, application you want to reverse proxy to vps server
 6# a nginx server is running in 192.168.254.101:80
 7
 8# PostUp
 9PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
10# Nexcloud port-forward
11PostUP = iptables -t nat -A PREROUTING -p tcp -i wg0 --dport 8080 -j DNAT --to 192.168.254.101:80
12# PostDown
13PostDown = iptables -t nat -D PREROUTING -s tcp -i wg0 --dport 8080 -j DNAT --to 192.168.254.101:80
14PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
15
16[Peer]
17PublicKey = 6F8h1/L2xwYLXe32ffBA+97pjVDsPJ7/uFkAT/OMChM=
18Endpoint = <YOUR_PUBLIC_IP>:51820
19AllowedIPs = 10.0.0.0/24
20PersistentKeepalive = 25

Enable and start WireGuard service.

1sudo systemctl enable wg-quick@wg0.service
2sudo systemctl start wg-quick@wg0.service

Caddy (VPS)

My domain is hosted in Cloudflare so we’ll be using ghcr.io/caddybuilds/caddy-cloudflare:latest image.

compose.yaml

 1services:
 2  caddy:
 3    image: ghcr.io/caddybuilds/caddy-cloudflare:latest
 4    container_name: caddy
 5    restart: unless-stopped
 6    cap_add:
 7      - NET_ADMIN
 8    ports:
 9      - 80:80
10      - 443:443
11      - 443:443/udp
12    volumes:
13      - /srv/volume/caddy/Caddyfile:/etc/caddy/Caddyfile
14      - /srv/volume/caddy/srv:/srv
15      - /srv/volume/caddy/data:/data
16      - /srv/volume/caddy/config:/config
17    environment:
18      - CLOUDFLARE_API_TOKEN=jn4cKIBcdYT6UqUwtQeMZPyif1IC8ATPGlv3Pwlt
19    networks:
20      - caddy
21    labels:
22      - homepage.group=Utilities
23      - homepage.name=Caddy
24      - homepage.icon=caddy
25      - homepage.description=Open source web server with automatic HTTPS
26      - homepage.widget.type=caddy
27      - homepage.widget.url=http://caddy:2019
28    healthcheck:
29
30      test:
31        - CMD-SHELL
32        - nc -z 127.0.0.1 80 || exit 1
33      interval: 30s
34      timeout: 5s
35      retries: 3
36      start_period: 10s
37networks:
38  caddy:
39    name: caddy
40    external: true

Make sure to create mount dir, for my setup it is in /srv/volume/caddy

1mkdir /srv/volume/caddy

Create Caddyfile and add you Cloudflare API token, check this post if you haven’t.

1touch /srv/volume/caddy/Caddyfile

Caddyfile

 1{
 2  admin :2019
 3}
 4
 5*.<YOUR_DOMAIN> {
 6
 7  tls {
 8    dns cloudflare <CLOUDFLARE_API_TOKEN>
 9  }
10
11  # Add this config to tunnel your application.
12
13  @test host test.<YOUR_DOMAIN>
14  handle @test {
15    reverse_proxy 10.0.0.2:8080
16  }
17}

Create docker network and start Caddy.

1docker network create caddy
2docker compose up -d

Verify.

 1curl https://test.marktaguiad.dev
 2<!DOCTYPE html>
 3<html>
 4<head>
 5<title>Welcome to nginx!</title>
 6<style>
 7html { color-scheme: light dark; }
 8body { width: 35em; margin: 0 auto;
 9font-family: Tahoma, Verdana, Arial, sans-serif; }
10</style>
11</head>
12<body>
13<h1>Welcome to nginx!</h1>
14<p>If you see this page, the nginx web server is successfully installed and
15working. Further configuration is required.</p>
16
17<p>For online documentation and support please refer to
18<a href="http://nginx.org/">nginx.org</a>.<br/>
19Commercial support is available at
20<a href="http://nginx.com/">nginx.com</a>.</p>
21
22<p><em>Thank you for using nginx.</em></p>
23</body>
24</html>
imagen

Docker Network

This just add complexity in your setup, but if you are cautious about security-since ports will be expose in you Host IP then I recommend this setup.

Create docker network, let’s call this wg.

1docker network create \
2  --driver bridge \
3  --subnet 172.30.0.0/24 \
4  wg

Run docker application, let’s use nginx as example.

compose.yaml

 1services:
 2  app:
 3    image: nginx
 4    container_name: app
 5
 6    networks:
 7      wg:
 8        ipv4_address: 172.30.0.69
 9
10networks:
11  wg:
12    external: true

In you wg0.conf add this. Note that you can use any dport (8069), just make sure it doesn’t conflict with other application you are reverse proxying.

 1[Interface]
 2PrivateKey = 8DW1ba8GHfA9hKAXrm17ssWX0aXQ4Za2ozSsHN6c1Z0c=
 3Address = 10.0.0.2/32
 4
 5PostUp = iptables -t nat -A PREROUTING -i wg0 -p tcp --dport 8069 -j DNAT --to-destination 172.30.0.69:80
 6PostUp = iptables -A FORWARD -p tcp -d 172.30.0.69 --dport 80 -j ACCEPT
 7
 8PostDown = iptables -t nat -D PREROUTING -i wg0 -p tcp --dport 8069 -j DNAT --to-destination 172.30.0.69:8080
 9PostDown = iptables -D FORWARD -p tcp -d 172.30.0.69 --dport 8069 -j ACCEPT
10
11[Peer]
12PublicKey = 6F8h1/L2xwYLXe32ffBA+97pjVDsPJ7/uFkAT/OMChM=
13Endpoint = <YOUR_PUBLIC_IP>:51820
14AllowedIPs = 10.0.0.0/8
15PersistentKeepalive = 25

In your Caddyfile you add.

Caddyfile

 1{
 2  admin :2019
 3}
 4
 5*.<YOUR_DOMAIN> {
 6
 7  tls {
 8    dns cloudflare <CLOUDFLARE_API_TOKEN>
 9  }
10
11  # Add this config to tunnel your application.
12
13  @web host web.<YOUR_DOMAIN>
14  handle @web {
15    reverse_proxy 10.0.0.2:8069
16  }
17}