# 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:

```bash
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:

```dotenv
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`:

```yaml
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:

```bash
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:

```bash
openssl rand -hex 32
```

Create a separate Basic Auth password for the Traefik dashboard:

```bash
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:

```bash
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
```

Follow the container startup and certificate issuance logs with:

```bash
docker compose logs -f traefik wireguard wireguard-ui
```

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

```bash
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`.

```text
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:

```text
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:

```bash
docker compose restart wireguard
```

Display the tunnel status and latest handshake time with:

```bash
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:

```bash
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.

