Cài wg-easy WireGuard trên Docker có web UI
Chạy WireGuard với wg-easy trên Docker Compose: cấu hình port, NET_ADMIN, sysctl cần thiết và cách dùng QR code để thêm VPN trên điện thoại.
Bạn đang xây dựng gì
wg-easy là WireGuard có giao diện web, chạy trong một Docker container duy nhất. Nó tự quản lý WireGuard interface và thêm giao diện trên trình duyệt để tạo client. Mỗi client bạn 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 bên dưới vẫn là WireGuard thông thường. Kernel module xử lý việc chuyển packet, nên throughput tương đương một cấu hình tự viết. Lợi ích nằm ở việc quản lý vòng đời client: thêm, vô hiệu hóa và xóa peer mà không phải chỉnh sửa file cấu hình qua SSH. Đổi lại, bạn mất quyền kiểm soát trực tiếp file cấu hình đó. Nội dung này được trình bày trong hướng dẫn cấu hình WireGuard thủ công trên VPS.
Bạn cần một Linux host có thể tự quản lý, chẳng hạn KVM VPS, với public IP hoặc domain trỏ đến host đó, CPU x86_64 hoặc arm64, Docker Engine có Compose plugin và quyền root. Container virtualisation 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ể đưa interface lên.
Version 15 chuyển phần cài đặt ra khỏi environment
Phần lớn hướng dẫn bạn tìm được đều viết cho wg-easy 14. Ở 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 đều là environment variable. Version 15 được viết lại. Tài liệu migration chính thức nêu rõ rằng v15 không dùng cùng các environment variable như v14 và phần lớn các biến này đã đượ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 copy một compose file cũ, container vẫn khởi động, bỏ qua các dòng đó, rồi yêu cầu bạn tạo admin account trong trình duyệt. Đây không phải bug. Đây là flow setup mới.
Tính đến October 2026, major tag cần pin là 15. Hãy pin major version thay vì dùng latest, vì major upgrade sẽ thay đổi config format trên disk và không thể rollback sạch. Việc chuyển sang version 15 đã thay file JSON của v14 bằng SQLite database.
Tệp compose
Tạo một thư mục cho stack rồi ghi file compose chính thức vào đó. Đây là file từ upstream, đã lược bỏ các dòng tùy chọn đang bị comment.
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 có dạng 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à một named volume chứa server key và mọi client bạn tạo, được lưu trong database SQLite wg-easy.db. Hãy backup volume này. Nếu không, thao tác rebuild sẽ xóa toàn bộ peer. Nếu muốn thấy các file đó trên filesystem của host, hãy thay volume này bằng bind mount. Đọc sự khác nhau giữa bind mount và named volume trước khi thực hiện, vì cách xử lý permission 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. Mỗi dòng trong số 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 route. Nếu thiếu capability này, container khởi động rồi dừng khi đư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 nạp kernel module WireGuard nếu host chưa nạp module này. Module nằm trong kernel của host, không nằm bên trong image. Vì vậy, thư mục trên host phải được mount để container nhìn thấy. Trên kernel hiện đại, module thường đã được build 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 forward 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 và handshake thành công, nhưng mọi packet đi đến Internet đều bị drop. Khi đó ping 1.1.1.1 bị timeout dù 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 outgoing của chính nó để chúng không bị route ngược vào tunnel. Reverse path filtering ở chế độ strict thấy source address của packet không khớp với route dự kiến nên 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 hỏng 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 -fHãy dùng docker compose up và docker compose down, không dùng start và stop. start chỉ khởi động lại các container đã tồn tại, nên mọi thay đổi trong compose file, bao gồm network và các thiết lập sysctl, đều không được áp dụng. Trong khi đó, up sẽ tạo lại những phần đã thay đổi. Nếu muốn stack tự khôi phục sau khi reboot, restart: unless-stopped đã xử lý việc đó. Hành vi khởi động của các compose service giải thích chính sách này đảm bảo và không đảm bảo điều gì.
Web UI lắng nghe trên TCP 51821. Khi truy cập lần đầu, 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 này được ghi vào dòng Endpoint trong cấu hình của mọi client. 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 hoàn tất.
Còn một điểm cần lưu ý về cổng này: wg-easy 15 từ chối HTTP thuần nếu bạn không đặt INSECURE=true. Truy cập nó qua HTTPS với certificate không được tin cậy, hoặc terminate TLS tại reverse proxy đặt phía trước nó, đều được. Truy cập qua http:// với thiết lập mặc định thì không được.
Không public UI port ra Internet
File compose publish cổng 51821 trên mọi interface. Đây là trang đăng nhập của một box có thể định tuyến traffic của bạn, nên không được mở cho toàn Internet. Khi Docker publish một port, 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 port đó. Đây là một bẫy đáng hiểu rõ, và vì sao các port do Docker publish bỏ qua ufw giải thích đầy đủ vấn đề nà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 đó, trên laptop của bạn:
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, port không trả lời bất kỳ máy nào khác, và INSECURE=true an toàn trong trường hợp này vì hop HTTP thuần không bao giờ rời khỏi loopback interface.
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 thêm một network firewall phía trước VPS mà Docker không biết đến. Hãy mở cổng ở cả hai nơi. Nếu bạn quản lý host firewall 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ự 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, hãy tạo một client và đặt tên dễ nhận biết về sau, chẳng hạn theo thiết bị mà client đó thuộc về. wg-easy sẽ cấp địa chỉ tunnel còn trống tiếp theo và tự tạo key pair cho bạn. Mỗi dòng client có QR code và 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ẽ hiển thị với tên bạn đã nhập. Bật tunnel. Dòng client trong UI sẽ bắt đầu hiển thị bộ đếm dữ liệu truyền 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 dịch vụ bạn chưa từng public lên Internet. Nhờ đó, điện thoại vẫn có thể upload ả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 áp dụng cho media. Thư viện Jellyfin được dựng lại như một cửa hàng băng video thập niên 90 là thứ khá tiện để duyệt từ phòng khách sạn, trong khi vẫn riêng tư như khi truy cập từ LAN. Alert cũng hoạt động theo chiều ngược lại trên cùng tunnel. ntfy server tự host có thể push message đến điện thoại ngay khi một backup job fail mà không phải trả lời bất kỳ request nào từ public Internet.
Client không hiển thị handshake sau khi được bật thì hoàn toàn chưa kết nối được đến server. Hãy kiểm tra UDP 51820, cả trên firewall của provider và trong endpoint address được ghi sẵn trong config. Client có handshake nhưng Internet không hoạt động thì thường liên quan đến forwarding hoặc DNS.
Trên desktop, hãy 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 và 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 nào nên vượt qua giới hạn 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 chỉnh sửa file cấu hình, và việc revoke một điện thoại bị mất chỉ cần một click.
Bạn sẽ gặp giới hạn khi cần một tính năng mà UI không mô hình hóa được. Site-to-site routing, trong đó AllowedIPs của một peer bao phủ toàn bộ remote subnet thay vì một địa chỉ đơn lẻ, thường là giới hạn đầu tiên. Split tunnel với routing rule riêng cho từng peer, hoặc cấu hình được tạo bởi provisioning tool, là những nhu cầu tiếp theo. Khi đó, cấu hình thủ công không khó hơn; nó chỉ khác cách triển khai. Hướng dẫn WireGuard cơ bản trình bày 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 lựa chọn managed. Lựa chọn này có phù hợp 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 tin cậy của Tailscale trước khi giao network của mình cho nó. Chi phí thường là câu hỏi tiếp theo. Gói miễn phí của Tailscale thực sự bao gồm những gì cho phép tối đa 6 người dùng miễn phí, chỉ dành cho mục đích phi thương mại. Vì vậy, một hộ gia đình không phải trả phí, còn một nhóm doanh nghiệp nhỏ sẽ không đủ điều kiện dù có bao nhiêu người. 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í, nên hãy kiểm tra Tailscale có giá bao nhiêu sau khi vượt qua gói miễn phí trước khi migrate một team. Full tunnel bạn vừa xây dựng cũng có cách triển khai tương đương ở đó: 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 cấu hình của từng client. Subnet cũng có cách tương đương, vì quảng bá toàn bộ private network từ VPS sẽ cấp network đó cho mọi device trong tailnet mà không cần chỉnh sửa AllowedIPs trên từng peer, vốn là lý do khiến bạn rời UI. Nếu muốn có dashboard và automatic mesh routing 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 hardware do bạn sở hữu. Đổi lại, bạn phải tự cấu hình DNS và TLS, những thứ wg-easy không yêu cầu.
Nếu phần không quen thuộc trong cú pháp compose ở trên là Docker chứ không phải WireGuard, Docker Compose cơ bản trên VPS giải thích format của file và các command thường dùng.
FAQ
Tại sao wg-easy bỏ qua WG_HOST và PASSWORD_HASH?
Các biến đó 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 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. Xóa capability đó 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 là lỗi forwarding. 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 được chỉnh sửa thủ công thường làm mất 2 mục này. 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 không còn truy cập được sẽ hiển thị giống hệt một kết nối đã chết trong browser.
Tôi sao lưu các client bằng cách nào?
Mọi dữ liệu đều nằm trong named volume etc_wireguard. Với version 15, đó là SQLite database tại /etc/wireguard/wg-easy.db bên trong container. Tài liệu v15 không mô tả tính năng backup hoặc export, nên hãy backup chính volume này: chạy docker compose down, copy nội dung của volume đến một nơi bên ngoài server, rồi khởi động lại stack bằng docker compose up -d. Hãy thực hiện trước mọi lần upgrade. File wg0.json được các hướng dẫn cũ đề cập thuộc về wg-easy 14. Đây là file mà nút backup của v14 export ra; trong v15, công dụng duy nhất của file này là file bạn upload trong setup wizard khi migrate một installation cũ.
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 đó, và đặt INSECURE=true trên container để container chấp nhận hop HTTP không mã hóa từ proxy. Vẫn publish UDP 51820 trực tiếp, vì VPN traffic sử dụng UDP và không đi qua HTTP proxy.