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