Contents

Run WireGuard and WireGuard UI behind Traefik

This guide deploys a small VPN gateway on a public Ubuntu virtual machine. Traefik is the only container that publishes host ports:

  • TCP 80 and 443 for HTTPS and Let’s Encrypt;
  • UDP 51820 for WireGuard;
  • WireGuard UI and the Traefik dashboard are available only over HTTPS.

This setup does not require a separate router or port forwarding. The virtual machine must have a public IP address, and its cloud firewall must allow incoming TCP 80, TCP 443, and UDP 51820 traffic.

Architecture

Component Purpose Published through
Traefik TLS termination, HTTP and UDP routing Host ports 80, 443, and 51820/udp
WireGuard VPN server Traefik UDP router
WireGuard UI Peer and server configuration Traefik HTTPS router
WireGuard reloader Applies changes made in the UI Docker socket
Whoami Verifies DNS, TLS, and HTTP routing Traefik HTTPS router

Prerequisites

Create three DNS records pointing to the server’s public IP address:

Record Purpose
vpn.example.com WireGuard endpoint and test page
wg.example.com WireGuard UI
traefik.example.com Traefik dashboard

Install Docker Engine and the Compose plugin. On a fresh Ubuntu server, you can use Docker’s official convenience script:

1
2
3
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker "$USER"

Log out of the SSH session and reconnect after adding your user to the Docker group.

Files and secrets

Create .env.example with the public hostnames and initial credentials:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
TZ=Etc/UTC
PUID=1000
PGID=1000

# All three A/AAAA records must point to this server.
WG_HOST=vpn.example.com
WG_UI_HOST=wg.example.com
TRAEFIK_HOST=traefik.example.com
ACME_EMAIL=admin@example.com

# Change these values before the first start.
WGUI_USERNAME=admin
WGUI_PASSWORD=change-me
SESSION_SECRET=replace-with-openssl-rand-hex-32

Create compose.yaml:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
name: wireguard

services:
  traefik:
    image: traefik:v3.6
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - proxy
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=proxy
      - --api.dashboard=true
      - --api.insecure=false
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --entrypoints.wireguard.address=:51820/udp
      - --certificatesresolvers.le.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.le.acme.storage=/acme/acme.json
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - 80:80
      - 443:443
      - 51820:51820/udp
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik/auth:/etc/traefik/auth:ro
      - ./acme:/acme
    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_HOST}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls=true
      - traefik.http.routers.dashboard.tls.certresolver=le
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.usersfile=/etc/traefik/auth/dashboard.htpasswd

  wireguard:
    image: lscr.io/linuxserver/wireguard:latest
    container_name: wireguard
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    networks:
      - proxy
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - SERVERURL=${WG_HOST}
      - SERVERPORT=51820
    volumes:
      - ./config:/config
      - /lib/modules:/lib/modules:ro
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv4.ip_forward=1
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      - traefik.udp.routers.wireguard.entrypoints=wireguard
      - traefik.udp.routers.wireguard.service=wireguard
      - traefik.udp.services.wireguard.loadbalancer.server.port=51820

  wireguard-ui:
    image: ngoduykhanh/wireguard-ui:latest
    container_name: wireguard-ui
    restart: unless-stopped
    depends_on:
      - wireguard
    networks:
      - proxy
    environment:
      - SESSION_SECRET=${SESSION_SECRET}
      - WGUI_USERNAME=${WGUI_USERNAME}
      - WGUI_PASSWORD=${WGUI_PASSWORD}
      - WGUI_MANAGE_START=false
      - WGUI_MANAGE_RESTART=false
    volumes:
      - ./db:/app/db
      - ./config:/etc/wireguard
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      - traefik.http.routers.wg-ui.rule=Host(`${WG_UI_HOST}`)
      - traefik.http.routers.wg-ui.entrypoints=websecure
      - traefik.http.routers.wg-ui.tls=true
      - traefik.http.routers.wg-ui.tls.certresolver=le
      - traefik.http.services.wg-ui.loadbalancer.server.port=5000

  wireguard-reloader:
    image: docker:29-cli
    container_name: wireguard-reloader
    restart: unless-stopped
    depends_on:
      - wireguard
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./config:/config:ro
    command:
      - sh
      - -c
      - |
        set -eu
        file=/config/wg_confs/wg0.conf
        last=""
        while true; do
          if [ -f "$$file" ]; then
            current="$$(sha256sum "$$file" | awk '{print $$1}')"
            if [ -n "$$last" ] && [ "$$current" != "$$last" ]; then
              echo "wg0.conf changed, restarting wireguard"
              sleep 2
              docker restart wireguard
            fi
            last="$$current"
          fi
          sleep 5
        done        

  whoami:
    image: traefik/whoami:v1.11
    container_name: whoami
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      - traefik.http.routers.whoami.rule=Host(`${WG_HOST}`)
      - traefik.http.routers.whoami.entrypoints=websecure
      - traefik.http.routers.whoami.tls=true
      - traefik.http.routers.whoami.tls.certresolver=le
      - traefik.http.services.whoami.loadbalancer.server.port=80

networks:
  proxy:
    name: proxy

Copy .env.example to .env, then prepare the persistent data directories:

1
2
3
4
cp .env.example .env
mkdir -p acme config db traefik/auth
touch acme/acme.json
chmod 600 acme/acme.json

Edit .env and replace the example domains, Let’s Encrypt email address, WireGuard UI username, and password. Check that PUID and PGID match the output of id -u and id -g. Generate the session secret with:

1
openssl rand -hex 32

Create a separate Basic Auth password for the Traefik dashboard:

1
2
3
4
read -s DASHBOARD_PASSWORD
docker run --rm httpd:2.4-alpine \
  htpasswd -nbB admin "$DASHBOARD_PASSWORD" > traefik/auth/dashboard.htpasswd
unset DASHBOARD_PASSWORD

Important: Do not commit .env, acme/acme.json, the config and db directories, or dashboard.htpasswd.

Starting the stack

Validate the rendered configuration and start the services:

1
2
3
4
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps

Follow the container startup and certificate issuance logs with:

1
docker compose logs -f traefik wireguard wireguard-ui

Once the DNS records have propagated, check the test service:

1
curl -I https://vpn.example.com/

The following pages should now be available:

  • https://wg.example.com for WireGuard UI;
  • https://traefik.example.com/dashboard/ for the Traefik dashboard. The trailing slash in /dashboard/ is required.

Configuring WireGuard UI

Open Global Settings and enter:

Setting Value
Endpoint Address vpn.example.com
Listen Port 51820
Server Interface 10.252.1.1/24
Config File Path /etc/wireguard/wg_confs/wg0.conf

Add the hooks below so VPN clients can access the Internet through the server. The container’s outbound interface is named eth0 in this example. You can verify it after startup with docker exec wireguard ip route show default.

1
2
Post Up:   iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
Post Down: iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

Create a client with an address such as 10.252.1.2/32. Use the following client settings to route all IPv4 traffic through the VPN:

1
2
Allowed IPs: 0.0.0.0/0
DNS:         1.1.1.1

Save the configuration, then download the client configuration file or scan its QR code. wireguard-reloader detects changes to wg0.conf and restarts the WireGuard container. To apply changes manually, run:

1
docker compose restart wireguard

Display the tunnel status and latest handshake time with:

1
docker exec wireguard wg show

How traffic flows

Traefik terminates TLS for both web interfaces. Its Docker provider discovers the HTTP services from their labels. WireGuard uses a separate UDP entry point: datagrams received on host port 51820 are forwarded to port 51820 in the WireGuard container.

Traefik does not configure the VPN and is not a DNS server. WireGuard UI writes wg0.conf, while the Linux kernel and iptables handle routing and NAT.

Updates, backups, and security

Update the images and recreate the containers with:

1
2
docker compose pull
docker compose up -d

Back up .env, the config and db directories, acme/acme.json, and traefik/auth/dashboard.htpasswd.

The wireguard-reloader service mounts /var/run/docker.sock, which effectively grants it broad control over Docker. This is a convenient tradeoff for a small personal server. In a stricter environment, use a Docker socket proxy or a host-side systemd watcher instead.

For additional protection, restrict the administration domains by source IP, place them behind an identity-aware proxy, or make them accessible only through the VPN. The Traefik dashboard is protected by Basic Auth in this example, while WireGuard UI uses its own authentication.