wg-easy WireGuard Docker: web UI và QR code
Chạy WireGuard với wg-easy trên Docker Compose: lưu ý khác biệt v14 và v15, port, NET_ADMIN, sysctl và QR code để thêm VPN trên điện thoại.
Bạn sẽ xây dựng gì
wg-easy là WireGuard có web interface, chạy trong một Docker container. Nó quản lý WireGuard interface và cung cấp browser UI để tạo client. Mỗi client được tạo sẽ có một file cấu hình và một QR code, vì vậy điện thoại có thể tham gia VPN bằng cách hướng camera vào màn hình.
Tunnel vẫn là WireGuard thông thường. Kernel module xử lý các packet, nên throughput giống như cấu hình thủ công. Bạn có thêm khả năng quản lý vòng đời client: thêm, vô hiệu hóa và xóa peer mà không cần chỉnh sửa file cấu hình qua SSH. Đổi lại, bạn không còn toàn quyền kiểm soát file cấu hình đó. Nội dung này được trình bày trong phần thiết lập WireGuard thủ công trên VPS.
Bạn cần một KVM VPS có địa chỉ IPv4 public, Docker Engine kèm Compose plugin và quyền root. Công nghệ container virtualization dùng chung kernel của host, chẳng hạn OpenVZ hoặc LXC, thường không thể load WireGuard module. Khi đó container sẽ không thể khởi động interface.
Phiên bản 15 chuyển cấu hình ra khỏi environment
Phần lớn hướng dẫn bạn tìm được đều viết cho wg-easy 14. Trong phiên bản đó, bạn đặt WG_HOST thành địa chỉ server và PASSWORD_HASH thành bcrypt hash của mật khẩu admin, cả hai dưới dạng biến môi trường. Phiên bản 15 được viết lại. Ghi chú migration chính thức nêu rõ rằng v15 không dùng cùng các biến môi trường như v14 và phần lớn cấu hình đã được chuyển vào admin panel trong web UI.
Vì vậy, WG_HOST và PASSWORD_HASH không còn tác dụng. Nếu bạn sao chép một file compose cũ, container vẫn khởi động, bỏ qua các dòng đó rồi yêu cầu bạn tạo tài khoản admin trong trình duyệt. Đây không phải bug. Đó là quy trình setup mới.
Tính đến tháng 7 năm 2026, major tag nên pin là 15. Hãy pin major version thay vì dùng latest, vì major upgrade sẽ thay đổi format config trên disk và không thể rollback sạch sẽ.
Tệp compose
Tạo một thư mục cho stack rồi ghi tệp compose chính thức vào đó. Đây là tệp từ upstream, không chỉnh sửa.
sudo mkdir -p /etc/docker/containers/wg-easy
sudo curl -o /etc/docker/containers/wg-easy/docker-compose.yml \
https://raw.githubusercontent.com/wg-easy/wg-easy/master/docker-compose.ymlNội dung tệp như sau:
volumes:
etc_wireguard:
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
networks:
wg:
ipv4_address: 10.42.42.42
ipv6_address: fdcc:ad94:bacf:61a3::2a
volumes:
- etc_wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "51820:51820/udp"
- "51821:51821/tcp"
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.default.forwarding=1
networks:
wg:
driver: bridge
enable_ipv6: true
ipam:
driver: default
config:
- subnet: 10.42.42.0/24
- subnet: fdcc:ad94:bacf:61a3::/64etc_wireguard là named volume chứa server key và mọi client mà bạn tạo. Hãy sao lưu volume này. Nếu không, việc build lại sẽ xóa toàn bộ peer. Nếu muốn thấy các tệp đó trên filesystem của host, hãy thay volume này bằng bind mount. Trước khi làm vậy, hãy đọc sự khác nhau giữa bind mount và named volume, vì quyền truy cập được xử lý khác nhau.
Vì sao cần NET_ADMIN, SYS_MODULE và các sysctl
Mặc định, container không được phép tác động vào network stack, và mỗi dòng này gỡ bỏ một rào cản cụ thể.
NET_ADMIN cho phép container tạo interface wg0, gán địa chỉ cho interface và ghi các route. Nếu thiếu quyền này, container khởi động rồi dừng trong lúc đưa interface lên vì ip link add wg0 type wireguard trả về Operation not permitted.
SYS_MODULE cùng với mount /lib/modules chỉ đọc cho phép container tải kernel module WireGuard nếu host chưa tải module đó. Module nằm trong kernel của host, không nằm bên trong image, nên thư mục trên host phải được hiển thị trong container. Trên kernel hiện đại, module này thường đã được tích hợp sẵn. Bạn có thể xác nhận bằng sudo modprobe wireguard && echo ok trên host.
net.ipv4.ip_forward=1 khiến kernel chuyển tiếp các packet không được gửi đến chính máy đó. Nếu thiếu sysctl này, client kết nối, handshake thành công, nhưng mọi packet đi đến Internet đều bị drop, khiến ping 1.1.1.1 timeout trong khi VPN vẫn hiển thị là đã kết nối.
net.ipv4.conf.all.src_valid_mark=1 là mục khiến nhiều người bất ngờ. WireGuard đánh dấu các packet đi ra của chính nó để chúng không bị route ngược vào tunnel. Reverse path filtering nghiêm ngặt thấy source address của packet không khớp với route dự kiến và drop packet đó. Sysctl này yêu cầu kernel chấp nhận các packet đã được đánh dấu, nhờ đó full tunnel không tự làm gián đoạn kết nối của chính nó.
Khởi động và tạo tài khoản admin
cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -fDùng docker compose up và docker compose down, không dùng start và stop. Upstream cảnh báo rằng chạy start trên một container được tạo với thiết lập khác sẽ khiến network chuyển sang trạng thái không nhất quán. Nếu muốn stack tự khởi động lại sau reboot, restart: unless-stopped đã xử lý việc đó. Phần giải thích hành vi khi boot của các service compose nêu rõ chính sách này đảm bảo và không đảm bảo những gì.
Web UI lắng nghe trên TCP 51821. Trong lần truy cập đầu tiên, UI hiển thị trang thiết lập để bạn tạo tài khoản admin và xác nhận địa chỉ host mà client sẽ dùng để kết nối đến server. Địa chỉ host đó được ghi vào dòng Endpoint trong mọi client config. Vì vậy, địa chỉ này phải là public IP hoặc tên DNS của VPS. Nếu nhập sai, QR code bạn đưa cho điện thoại sẽ trỏ đến một địa chỉ không thể truy cập và handshake sẽ không bao giờ hoàn tất.
Còn một điểm cần lưu ý về port này: wg-easy 15 từ chối HTTP thuần nếu bạn không đặt INSECURE=true. Truy cập qua HTTPS với certificate không được tin cậy, hoặc terminate TLS tại một reverse proxy phía trước wg-easy, đều được. Với thiết lập mặc định, không được truy cập qua http://.
Không public cổng UI ra Internet
File compose publish cổng 51821 trên mọi interface. Đây là trang đăng nhập vào một máy có thể định tuyến traffic của bạn, nên không được mở cho mọi nơi. Khi Docker publish một cổng, Docker ghi rule vào chain DOCKER. Chain này được xử lý trước ufw, nên rule deny của ufw không đóng được cổng đó. Đây là một bẫy đáng hiểu riêng, và bài vì sao các cổng do Docker publish bỏ qua ufw giải thích đầy đủ.
Cách đơn giản là bind UI vào loopback rồi truy cập qua SSH tunnel:
ports:
- "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp"
environment:
- INSECURE=trueSau đó chạy trên laptop:
ssh -L 51821:127.0.0.1:51821 youruser@your.server.addressMở http://127.0.0.1:51821 trong browser trên laptop. Traffic được SSH mã hóa, cổng không trả lời các máy khác, và INSECURE=true an toàn trong trường hợp này vì kết nối HTTP thuần không bao giờ rời khỏi interface loopback.
Mở UDP 51820 và kiểm tra cả hai firewall
WireGuard cần UDP 51820 có thể truy cập từ Internet. Docker publish cổng này, nhưng nhiều nhà cung cấp đặt một network firewall riêng phía trước VPS mà Docker không biết. Hãy mở cổng ở cả hai nơi. Nếu quản lý firewall của host bằng ufw, các rule ufw cơ bản cho VPS sẽ nhanh hơn so với tự viết nftables.
Kiểm tra container có thực sự đang listen hay không:
sudo ss -ulnp | grep 51820Bạn phải thấy một UDP socket đang listen. Nếu không có gì trên dòng đó, container chưa đưa interface lên và sudo docker compose logs wg-easy sẽ cho biết nguyên nhân.
Tạo client và quét trên điện thoại
Trong UI, tạo một client và đặt tên dễ nhận biết sau này, chẳng hạn tên thiết bị mà client đó thuộc về. wg-easy tự cấp địa chỉ tunnel còn trống tiếp theo và tạo key pair cho bạn. Mỗi dòng client có QR code và một file .conf để tải xuống.
Cài ứng dụng WireGuard chính thức trên điện thoại, chọn thêm tunnel từ QR code, rồi hướng camera vào mã trên màn hình. Tunnel sẽ xuất hiện với tên bạn đã nhập. Bật tunnel lên. Dòng client trong UI sẽ bắt đầu hiển thị bộ đếm lưu lượng và thời điểm handshake gần nhất. Khi điện thoại đã kết nối vào tunnel, nó có thể truy cập các service mà bạn chưa từng publish ra Internet. Nhờ đó, điện thoại vẫn có thể tải ảnh lên photo server tự host từ bất kỳ đâu mà server đó không cần mở một cổng nào ra Internet. Cách này cũng dùng được cho media. Thư viện Jellyfin được dựng lại như một cửa hàng video thập niên 90 là thứ khá tiện để duyệt trong phòng khách sạn, trong khi vẫn riêng tư như khi bạn truy cập từ LAN. Alert cũng hoạt động theo chiều ngược lại trên cùng tunnel. Server ntfy tự host có thể push message đến điện thoại ngay khi một backup job fail, mà không cần trả lời bất kỳ request nào từ public Internet.
Một client không hiển thị handshake sau khi được bật nghĩa là client hoàn toàn chưa kết nối được đến server. Hãy kiểm tra UDP 51820, ở firewall của provider hoặc ở địa chỉ endpoint được ghi sẵn trong config. Nếu client có handshake nhưng Internet không hoạt động, nguyên nhân thường nằm ở forwarding hoặc DNS.
Trên desktop, tải file .conf xuống và import vào WireGuard client thay vì nhập lại thủ công. Private key trong file này chỉ được tạo một lần và chỉ hiển thị một lần. Hãy bảo vệ file này như cách bạn bảo vệ SSH private key.
Khi đã vượt quá khả năng của UI
wg-easy phù hợp khi các peer của bạn là người dùng và điện thoại. UI nhanh hơn việc sửa file cấu hình, và việc thu hồi quyền của một điện thoại bị mất chỉ cần một lần nhấp.
Bạn sẽ gặp giới hạn khi cần một thứ mà UI không mô hình hóa được. Định tuyến site-to-site, trong đó AllowedIPs của một peer bao phủ toàn bộ subnet từ xa thay vì chỉ một địa chỉ, thường là giới hạn đầu tiên. Split tunnel với rule định tuyến riêng cho từng peer, hoặc config được tạo bằng provisioning tool, là các giới hạn tiếp theo. Đến lúc đó, việc setup thủ công không khó hơn; nó chỉ khác cách làm. Hướng dẫn WireGuard cơ bản mô tả cách tạo cùng tunnel từ wg0.conf. Nếu muốn ngừng chạy control plane hoàn toàn, WireGuard so với Tailscale trình bày tùy chọn managed. Lựa chọn này có đáng hay không phụ thuộc vào những gì coordination server thực sự có thể truy cập, và bạn nên đọc Mô hình trust của Tailscale trước khi giao quyền truy cập mạng cho nó. Chi phí thường là câu hỏi tiếp theo. Gói miễn phí Tailscale thực sự bao gồm những gì cho thấy hộ gia đình hoặc nhóm nhỏ thường không phải trả phí. Sau giới hạn đó, billing tính theo user thay vì device. Đây là cách tính khác với VPS mà bạn đã trả phí sẵn, vì vậy hãy kiểm tra Chi phí Tailscale khi đã vượt quá gói miễn phí trước khi migrate một team. Full tunnel bạn vừa tạo cũng có cách tương đương trên Tailscale. Quảng bá VPS làm Tailscale exit node tạo cùng route đi ra qua server. Route này được approve trong admin console thay vì ghi vào config của từng client. Giới hạn về subnet cũng có cách tương đương. Quảng bá toàn bộ private network từ VPS cung cấp network đó cho mọi device trong tailnet mà không cần sửa AllowedIPs riêng cho từng peer, vốn là nguyên nhân khiến bạn rời UI. Nếu muốn có dashboard và mesh routing tự động nhưng không muốn dùng coordination server của bên khác, Chạy NetBird server riêng trên VPS sẽ giữ control plane trên phần cứng bạn sở hữu. Đổi lại, bạn phải tự setup DNS và TLS, những thứ mà wg-easy không yêu cầu.
Nếu phần không quen thuộc ở trên là cú pháp compose chứ không phải WireGuard, Kiến thức cơ bản về Docker Compose trên VPS giải thích format của file và các command sử dụng hằng ngày.
FAQ
Vì sao wg-easy bỏ qua WG_HOST và PASSWORD_HASH?
Các biến này thuộc về wg-easy 14. Version 15 được viết lại, và upstream đã chuyển gần như toàn bộ cấu hình vào admin panel trong web UI. Container không đọc biến nào trong 2 biến này, nên vẫn khởi động bình thường rồi yêu cầu bạn tạo tài khoản admin trong lần truy cập đầu tiên. Hãy đặt địa chỉ host mà client sử dụng trên trang setup đó.
Tôi có cần SYS_MODULE nếu kernel đã có WireGuard không?
Không. SYS_MODULE và mount /lib/modules tồn tại để container có thể load module khi host chưa có module này. Trên host mà sudo modprobe wireguard đã chạy thành công, capability này không được sử dụng. Loại bỏ nó là một bước hardening hợp lý, còn NET_ADMIN vẫn bắt buộc trong mọi trường hợp.
Client kết nối được nhưng không có Internet. Lỗi ở đâu?
Handshake thành công nhưng không có traffic gần như luôn có nghĩa là forwarding chưa đúng. Xác nhận net.ipv4.ip_forward=1 và net.ipv4.conf.all.src_valid_mark=1 vẫn còn trong compose file, vì bản copy chỉnh sửa thủ công thường làm mất chúng. Nếu forwarding đã bật, hãy kiểm tra DNS server mà client nhận được. Một tunnel gửi toàn bộ traffic qua VPN nhưng trỏ đến DNS server mà nó không còn truy cập được sẽ trông giống hệt một kết nối chết trong browser.
Tôi sao lưu các client như thế nào?
Mọi dữ liệu nằm trong named volume etc_wireguard, trong file wg0.json. UI cũng có nút backup để export cùng dữ liệu đó. Hãy copy file này ra ngoài server trước mọi lần upgrade. Khôi phục được thực hiện bằng cách upload file trong bước setup trên một container mới.
Tôi có thể chạy wg-easy phía sau reverse proxy không?
Có. Đặt proxy phía trước TCP 51821, thực hiện TLS termination tại đó, rồi đặt INSECURE=true trên container để container chấp nhận hop HTTP thuần từ proxy. Giữ UDP 51820 được publish trực tiếp, vì traffic VPN dùng UDP và không đi qua HTTP proxy.