Docker로 wg-easy 구축하기: WireGuard 웹 UI 설정
Docker Compose를 사용하여 wg-easy를 설치하는 방법을 설명합니다. NET_ADMIN 권한 설정, 필수 sysctls 옵션, 그리고 v15 버전에서 변경된 환경 변수 설정 방식을 확인하여 VPN 서버를 안정적으로 구축하십시오.
구축할 내용
wg-easy는 웹 인터페이스를 갖춘 WireGuard이며, 하나의 Docker 컨테이너로 실행됩니다. 이 도구는 WireGuard 인터페이스를 대신 관리해주며, 클라이언트를 생성할 수 있는 브라우저 UI를 제공합니다. 생성하는 모든 클라이언트는 설정 파일과 QR 코드를 받게 되며, 스마트폰 카메라로 화면을 비추는 것만으로 VPN에 연결할 수 있습니다.
터널 자체는 일반적인 WireGuard와 동일합니다. 커널 모듈이 패킷을 처리하므로 처리량은 직접 설정한 경우와 같습니다. 이 도구를 통해 얻는 이점은 클라이언트 수명 주기 관리입니다. SSH를 통해 설정 파일을 수정할 필요 없이 피어를 추가, 비활성화, 삭제할 수 있습니다. 대신 포기해야 하는 것은 설정 파일에 대한 직접적인 제어권이며, 이는 VPS에서 수동으로 WireGuard 설정하기에서 다룹니다.
관리 가능한 Linux 호스트(예: KVM VPS)가 필요하며, 공인 IP 주소나 이를 가리키는 도메인 이름, x86_64 또는 arm64 CPU, Docker Compose 플러그인이 포함된 Docker Engine, 그리고 root 권한이 필요합니다. OpenVZ나 LXC와 같이 호스트 커널을 공유하는 컨테이너 가상화 환경은 일반적으로 WireGuard 모듈을 로드할 수 없으며, 이 경우 컨테이너는 인터페이스를 활성화하지 못하고 실패합니다.
버전 15에서 설정이 환경 변수에서 분리됨
대부분의 가이드는 wg-easy 14 버전을 기준으로 작성되어 있습니다. 해당 버전에서는 WG_HOST에 서버 주소를, PASSWORD_HASH에 관리자 비밀번호의 bcrypt 해시를 환경 변수로 설정했습니다. 버전 15는 완전히 새로 작성되었습니다. 공식 마이그레이션 노트에 따르면 v15는 v14와 동일한 환경 변수를 사용하지 않으며, 대부분의 설정이 웹 UI의 관리자 패널로 이동했습니다.
따라서 WG_HOST과 PASSWORD_HASH는 더 이상 아무런 동작을 하지 않습니다. 이전 compose 파일을 그대로 복사해서 사용하면 컨테이너는 시작되지만 해당 줄을 무시하며, 브라우저에서 관리자 계정을 생성하라는 메시지가 나타납니다. 이는 버그가 아니라 새로운 설정 방식입니다.
2026년 10월 기준으로 고정해야 할 메이저 태그는 15입니다. 메이저 업그레이드는 디스크상의 설정 형식을 변경하며 깔끔하게 롤백되지 않으므로, latest을 사용하는 대신 메이저 버전을 고정하십시오. 15 버전으로의 전환을 통해 v14의 JSON 파일은 SQLite 데이터베이스로 대체되었습니다.
Compose 파일
스택을 위한 디렉터리를 생성하고 공식 compose 파일을 작성합니다. 이 파일은 주석 처리된 선택적 라인을 제외한 업스트림 파일입니다.
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.yml내용은 다음과 같습니다.
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는 서버 키와 생성하는 모든 클라이언트를 보관하는 명명된 볼륨(named volume)이며, SQLite 데이터베이스인 wg-easy.db에 저장됩니다. 이 볼륨을 백업하십시오. 그렇지 않으면 재구축 시 모든 피어 정보가 삭제됩니다. 호스트 파일 시스템에서 해당 파일을 직접 확인하려면 바인드 마운트(bind mount)로 교체하십시오. 단, 권한 처리 방식이 다르므로 실행 전에 바인드 마운트와 명명된 볼륨의 차이를 먼저 읽어보시기 바랍니다.
NET_ADMIN, SYS_MODULE 및 sysctl 설정이 필요한 이유
기본적으로 컨테이너는 네트워크 스택에 접근할 수 없으며, 각 설정 항목은 이러한 제한을 하나씩 해제합니다.
NET_ADMIN는 컨테이너가 wg0 인터페이스를 생성하고, 주소를 할당하며, 경로를 작성할 수 있게 합니다. 이 권한이 없으면 컨테이너가 시작된 후 인터페이스를 활성화하는 과정에서 ip link add wg0 type wireguard이 Operation not permitted을 반환하며 종료됩니다.
SYS_MODULE과 읽기 전용 /lib/modules 마운트를 조합하면 호스트에 WireGuard 커널 모듈이 로드되지 않았을 때 컨테이너가 이를 직접 로드할 수 있습니다. 모듈은 이미지 내부가 아닌 호스트 커널에 존재하므로 호스트 디렉터리를 컨테이너에 노출해야 합니다. 최신 커널에서는 모듈이 보통 내장되어 있으며, 호스트에서 sudo modprobe wireguard && echo ok 명령으로 이를 확인할 수 있습니다.
net.ipv4.ip_forward=1은 해당 서버로 향하지 않는 패킷을 커널이 전달하도록 설정합니다. 이 설정이 없으면 클라이언트가 연결되어 핸드셰이크는 성공하지만, 인터넷으로 향하는 모든 패킷이 폐기됩니다. 결과적으로 VPN은 연결된 것처럼 보이지만 ping 1.1.1.1는 타임아웃됩니다.
net.ipv4.conf.all.src_valid_mark=1은 사용자들을 당황하게 만드는 설정입니다. WireGuard는 자체적으로 송신하는 패킷에 마킹을 하여 터널 내부로 다시 라우팅되지 않도록 합니다. 엄격한 역방향 경로 필터링(reverse path filtering)은 출발지 주소가 예상 경로와 일치하지 않는 패킷을 폐기합니다. 이 sysctl 설정은 커널이 마킹된 패킷을 허용하도록 지시하며, 이를 통해 전체 터널이 스스로 차단되는 현상을 방지합니다.
서비스 시작 및 관리자 계정 생성
cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -fstart와 stop 대신 docker compose up와 docker compose down를 사용하십시오. start은 이미 존재하는 컨테이너만 재시작하므로, 네트워크나 sysctl 설정 등 compose 파일에 변경한 내용이 적용되지 않습니다. 반면 up은 변경된 사항을 반영하여 컨테이너를 다시 생성합니다. 재부팅 후에도 스택을 유지하려면 restart: unless-stopped 설정으로 충분하며, compose 서비스의 부팅 동작에서 해당 정책이 보장하는 범위와 그렇지 않은 범위를 확인할 수 있습니다.
웹 UI는 TCP 51821 포트에서 대기합니다. 처음 접속하면 관리자 계정을 생성하고 클라이언트가 서버에 연결할 때 사용할 호스트 주소를 확인하는 설정 페이지가 나타납니다. 이 호스트 주소는 모든 클라이언트 설정의 Endpoint 항목에 기록되므로, 반드시 VPS의 공인 IP나 도메인 이름이어야 합니다. 주소가 잘못되면 스마트폰에 전달한 QR 코드가 연결할 수 없는 곳을 가리키게 되어 핸드셰이크가 완료되지 않습니다.
해당 포트에 관해 한 가지 더 주의할 점이 있습니다. wg-easy는 자체적으로 TLS를 제공하지 않으며, 일반 HTTP 요청을 거부하지도 않습니다. http://을 통해 접속해도 UI는 로드되지만 경고 배너가 표시됩니다. 기본 설정에서 변경되는 것은 세션 쿠키의 Secure 플래그입니다. 공인 주소에서 일반 HTTP로 UI에 접속하면 브라우저가 이 플래그 때문에 쿠키를 폐기하여 로그인이 유지되지 않습니다. INSECURE=true는 이 플래그를 제거할 뿐 다른 기능은 수행하지 않습니다. TLS를 종료하는 리버스 프록시를 앞단에 배치하면 자체 서명 인증서를 사용하더라도 wg-easy 설정을 변경할 필요가 없습니다.
UI 포트를 인터넷에 공개하지 마십시오
compose 파일은 51821 포트를 모든 인터페이스에 공개합니다. 이는 트래픽을 라우팅할 수 있는 서버의 로그인 페이지이므로 외부 인터넷에 노출해서는 안 됩니다. Docker에서 포트를 공개하면 DOCKER 체인에 규칙이 작성되는데, 이는 ufw보다 먼저 평가되므로 ufw의 거부 규칙으로도 차단되지 않습니다. 이 함정은 이해해 둘 가치가 있으며, Docker가 공개한 포트가 ufw를 무시하는 이유에서 자세히 다룹니다.
간단한 해결책은 UI를 루프백 인터페이스에 바인딩하고 SSH 터널을 통해 접속하는 것입니다.
ports:
- "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp"
environment:
- INSECURE=true그다음 노트북에서 다음 명령을 실행하십시오.
ssh -L 51821:127.0.0.1:51821 youruser@your.server.address노트북 브라우저에서 http://127.0.0.1:51821에 접속하십시오. 트래픽은 SSH로 암호화되며 해당 포트는 외부에서 접근할 수 없습니다. INSECURE=true는 세션 쿠키에서 Secure 플래그만 제거하므로 브라우저가 일반 HTTP 주소에서도 로그인을 유지할 수 있게 합니다. 일반 HTTP 구간이 루프백 인터페이스를 벗어나지 않으므로 이 방식은 안전합니다.
UDP 51820 포트를 개방하고 양쪽 방화벽을 확인합니다
WireGuard는 인터넷에서 UDP 51820 포트에 접근할 수 있어야 합니다. Docker가 이 포트를 게시하더라도, 많은 호스팅 제공업체는 Docker가 인식하지 못하는 별도의 네트워크 방화벽을 VPS 앞단에 배치합니다. 따라서 두 곳 모두에서 포트를 개방해야 합니다. ufw로 호스트 방화벽을 관리한다면, nftables를 직접 작성하는 것보다 VPS를 위한 기본적인 ufw 규칙을 적용하는 것이 더 효율적입니다.
컨테이너가 실제로 리스닝 상태인지 확인하십시오:
sudo ss -ulnp | grep 51820UDP 소켓이 리스닝 상태로 표시되어야 합니다. 해당 줄에 아무것도 나타나지 않는다면 컨테이너가 인터페이스를 활성화하지 못한 것이며, sudo docker compose logs wg-easy에서 그 이유를 확인할 수 있습니다.
클라이언트를 생성하고 휴대폰에서 스캔하기
UI에서 클라이언트를 생성하고 나중에 알아볼 수 있는 이름(예: 해당 기기 이름)을 지정합니다. wg-easy는 다음으로 사용 가능한 터널 주소를 할당하고 키 쌍을 자동으로 생성합니다. 각 클라이언트 행에는 QR 코드와 다운로드 가능한 .conf 파일이 제공됩니다.
휴대폰에 공식 WireGuard 앱을 설치하고 QR 코드로 터널 추가를 선택한 뒤, 화면에 표시된 코드를 카메라로 촬영합니다. 입력한 이름으로 터널이 나타납니다. 터널을 활성화하면 UI의 클라이언트 행에 전송 카운터와 최근 핸드셰이크 시간이 표시되기 시작합니다. 휴대폰이 터널에 연결되면 인터넷에 공개하지 않은 서비스에도 접근할 수 있습니다. 이를 통해 휴대폰은 외부로 포트를 하나도 개방하지 않은 자체 호스팅 사진 서버에 어디서든 사진을 업로드할 수 있습니다. 같은 방식으로 미디어 서비스도 이용할 수 있으며, 90년대 비디오 가게처럼 재구성한 Jellyfin 라이브러리를 호텔 방에서 LAN에 있는 것처럼 안전하게 탐색할 수 있습니다. 알림은 같은 터널을 통해 반대 방향으로 작동합니다. 자체 호스팅 ntfy 서버는 공용 인터넷의 요청에 응답하지 않고도 백업 작업이 실패하는 즉시 휴대폰으로 메시지를 푸시할 수 있습니다.
클라이언트를 활성화한 후에도 핸드셰이크가 표시되지 않는다면 서버에 전혀 도달하지 못하는 상태입니다. 이는 공급자 방화벽이나 설정 파일에 포함된 엔드포인트 주소의 UDP 51820 포트 문제일 가능성이 큽니다. 핸드셰이크는 표시되지만 인터넷이 작동하지 않는다면 포워딩이나 DNS 설정을 확인해야 합니다.
데스크톱에서는 .conf 파일을 다운로드하여 WireGuard 클라이언트로 가져오기(import)를 수행하십시오. 직접 입력하는 것보다 훨씬 안전합니다. 해당 파일의 개인 키는 한 번만 생성되고 표시됩니다. 이 파일은 SSH 개인 키를 다루는 것과 동일한 수준으로 보안을 유지하십시오.
UI를 벗어나야 할 때
wg-easy는 피어가 사람이나 스마트폰인 경우에 적합한 도구입니다. UI는 설정 파일을 수정하는 것보다 빠르며, 분실한 스마트폰의 접근 권한을 취소하는 것도 클릭 한 번으로 가능합니다.
UI가 지원하지 않는 기능을 원하게 되면 한계에 부딪히게 됩니다. 피어의 AllowedIPs가 단일 주소가 아닌 전체 원격 서브넷을 포괄해야 하는 사이트 간(Site-to-site) 라우팅이 보통 첫 번째 장벽입니다. 피어별 라우팅 규칙이 포함된 스플릿 터널이나 프로비저닝 도구로 생성된 설정이 그다음입니다. 그 시점이 되면 수동 설정이 더 어려운 것이 아니라 단지 방식이 다를 뿐이며, 일반 WireGuard 가이드는 wg0.conf을 사용하여 동일한 터널을 구축하는 방법을 보여줍니다. 제어 평면을 직접 운영하고 싶지 않다면 WireGuard와 Tailscale 비교에서 관리형 옵션을 다룹니다. 이것이 공정한 거래인지는 조정 서버가 실제로 무엇에 접근할 수 있는지에 따라 달라지며, Tailscale의 신뢰 모델은 네트워크를 맡기기 전에 읽어볼 가치가 있습니다. 비용은 보통 그다음 고려 사항이며, Tailscale 무료 플랜의 실제 범위는 비상업적 용도에 한해 최대 6명의 사용자까지 무료이므로, 가정에서는 비용이 들지 않지만 소규모 비즈니스 팀은 인원수와 관계없이 자격이 되지 않습니다. 그 이후부터는 기기가 아닌 사용자를 기준으로 요금이 청구되는데, 이는 이미 비용을 지불 중인 VPS와는 다른 형태의 청구 방식이므로 무료 플랜을 초과했을 때의 Tailscale 비용을 팀 이주 전에 확인해야 합니다. 방금 구축한 전체 터널은 Tailscale에서도 직접적으로 구현 가능합니다. VPS를 Tailscale exit node로 광고하기를 사용하면 각 클라이언트 설정에 작성하는 대신 관리 콘솔에서 승인하는 방식으로 서버를 통한 동일한 경로를 얻을 수 있습니다. 서브넷 장벽 또한 해결책이 있습니다. VPS에서 전체 사설 네트워크 광고하기를 사용하면 UI를 벗어나게 만들었던 피어별 AllowedIPs 편집 없이도 해당 네트워크를 테일넷(tailnet)의 모든 기기에 전달할 수 있습니다. 대시보드와 자동 메시 라우팅 기능은 원하지만 타인의 조정 서버를 사용하고 싶지 않다면, VPS에서 직접 NetBird 서버 운영하기를 통해 제어 평면을 직접 소유한 하드웨어에 유지할 수 있습니다. 단, 이 경우 wg-easy에서는 요구하지 않았던 DNS 및 TLS 설정이 필요합니다.
위의 Compose 문법이 WireGuard보다 생소했다면, VPS에서의 Docker Compose 기초에서 파일 형식과 일상적인 명령어에 대해 설명합니다.
FAQ
wg-easy가 왜 WG_HOST와 PASSWORD_HASH를 무시하나요?
해당 변수들은 wg-easy 14 버전용입니다. 15 버전은 완전히 새로 작성되었으며, 업스트림에서 거의 모든 설정을 웹 UI 관리자 패널로 옮겼습니다. 컨테이너는 해당 변수를 읽지 않으므로 정상적으로 시작된 후 첫 접속 시 관리자 계정을 생성하라는 메시지를 표시합니다. 클라이언트용 호스트 주소는 해당 설정 페이지에서 지정하십시오.
커널에 이미 WireGuard가 있는데 SYS_MODULE이 필요한가요?
아니요. SYS_MODULE과 /lib/modules 마운트는 호스트에 모듈이 없을 때 컨테이너가 직접 모듈을 로드하기 위한 것입니다. sudo modprobe wireguard 명령이 이미 성공하는 호스트라면 해당 기능은 사용되지 않습니다. 이를 제거하는 것은 보안을 강화하는 합리적인 단계이며, 어느 경우든 NET_ADMIN는 여전히 필요합니다.
클라이언트는 연결되는데 인터넷이 안 됩니다. 무엇이 문제인가요?
핸드셰이크는 성공했으나 트래픽이 흐르지 않는다면 거의 항상 포워딩 문제입니다. compose 파일에 net.ipv4.ip_forward=1과 net.ipv4.conf.all.src_valid_mark=1이 포함되어 있는지 확인하십시오. 직접 수정하다 보면 누락되는 경우가 많습니다. 포워딩이 켜져 있다면 클라이언트가 할당받은 DNS 서버를 확인하십시오. 모든 트래픽을 VPN으로 보내는데 더 이상 도달할 수 없는 DNS 서버를 가리키고 있다면 브라우저에서는 연결이 끊긴 것처럼 보입니다.
클라이언트 설정은 어떻게 백업하나요?
모든 데이터는 etc_wireguard 명명된 볼륨에 저장됩니다. 15 버전에서는 컨테이너 내부의 /etc/wireguard/wg-easy.db 위치에 있는 SQLite 데이터베이스를 의미합니다. 15 버전 문서에는 백업이나 내보내기 기능이 명시되어 있지 않으므로 볼륨 자체를 백업하십시오. docker compose down를 실행하여 볼륨 내용을 서버 외부로 복사한 뒤, docker compose up -d로 스택을 다시 올리십시오. 업그레이드 전에 반드시 수행해야 합니다. 이전 가이드에서 언급하는 wg0.json 파일은 wg-easy 14 버전용입니다. 이는 v14의 백업 버튼으로 내보내는 파일이며, v15에서는 이전 설치 환경을 마이그레이션할 때 설정 마법사에서 업로드하는 용도로만 사용됩니다.
wg-easy를 리버스 프록시 뒤에서 실행할 수 있나요?
네. 웹 UI 포트 앞단에 프록시를 두고 거기서 TLS를 종료하십시오. INSECURE은 설정하지 않은 상태로 둡니다. 컨테이너는 프록시로부터 전달되는 일반 HTTP 요청을 그대로 수용하며, 공식 Caddy 및 Traefik 예제에서도 INSECURE 변수를 설정하지 않습니다. 브라우저가 프록시와 HTTPS로 통신하므로 세션 쿠키의 Secure 플래그는 유지되어야 하며, INSECURE=true는 바로 이 플래그를 제거하는 설정입니다. VPN 트래픽은 UDP이며 HTTP 프록시를 통과하지 않으므로 UDP 51820 포트는 직접 공개 상태로 유지하십시오.