Docker Compose ¶
You can run wg-access-server using the following example Docker Compose file.
Checkout the configuration docs to learn how wg-access-server can be configured.
Please also read the Docker instructions for general information regarding Docker deployments.
# wg-access-server with Docker Compose. More setups - behind Traefik, IPv4- or
# IPv6-only, with Docker secrets - are in docs/deployment/docker-compose.md.
services:
wg-access-server:
image: ghcr.io/freifunkmuc/wg-access-server:latest
# to build the image from source instead:
# build: .
container_name: wg-access-server
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv6.conf.all.forwarding: 1
volumes:
- "wg-access-server-data:/data"
# - "./config.yaml:/config.yaml:ro" # an optional config file
environment:
WG_ADMIN_PASSWORD: "${WG_ADMIN_PASSWORD:?set WG_ADMIN_PASSWORD, the password of the admin account}"
WG_WIREGUARD_PRIVATE_KEY: "${WG_WIREGUARD_PRIVATE_KEY:?set WG_WIREGUARD_PRIVATE_KEY, e.g. to the output of wg genkey}"
ports:
- "8443:8443/tcp" # the web UI, over HTTPS with a self-signed certificate
- "127.0.0.1:8000:8000/tcp" # the web UI over plain HTTP - only for this machine
- "51820:51820/udp" # WireGuard
devices:
- "/dev/net/tun:/dev/net/tun"
volumes:
wg-access-server-data:
Set the two variables and start it:
export WG_ADMIN_PASSWORD="<a password for the admin account>"
export WG_WIREGUARD_PRIVATE_KEY="$(wg genkey)"
docker compose up -d
Keep the private key: the device configurations handed out so far only work with it. Put the
variables into an .env file next to docker-compose.yml to have them at the next start - or use
Docker secrets.
The web UI is at https://<your-server>:8443, with a self-signed certificate. Plain HTTP on port
8000 is only reachable from the machine itself: it would carry passwords unencrypted.
Behind Traefik, with a Let's Encrypt certificate ¶
Traefik terminates TLS, so wg-access-server serves plain HTTP to it. Port 80 is needed for Let's Encrypt's challenge and redirects everything else to HTTPS.
services:
wg-access-server:
image: ghcr.io/freifunkmuc/wg-access-server:latest
container_name: wg-access-server
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv6.conf.all.forwarding: 1
volumes:
- "wg-access-server-data:/data"
environment:
WG_ADMIN_PASSWORD: "${WG_ADMIN_PASSWORD:?set WG_ADMIN_PASSWORD, the password of the admin account}"
WG_WIREGUARD_PRIVATE_KEY: "${WG_WIREGUARD_PRIVATE_KEY:?set WG_WIREGUARD_PRIVATE_KEY, e.g. to the output of wg genkey}"
WG_HTTPS_ENABLED: "false" # Traefik terminates TLS
# Believe Traefik's X-Forwarded-For, so that the logs, the audit trail
# and everybody's own list of sessions name the client rather than
# Traefik. Use the network Traefik reaches this container on.
WG_TRUSTED_PROXIES: "172.16.0.0/12"
ports:
- "51820:51820/udp"
devices:
- "/dev/net/tun:/dev/net/tun"
labels:
- "traefik.enable=true"
- "traefik.http.routers.vpn.rule=Host(`vpn.example.com`)"
- "traefik.http.routers.vpn.entrypoints=websecure"
- "traefik.http.routers.vpn.tls.certresolver=letsencrypt"
- "traefik.http.services.vpn.loadbalancer.server.port=8000"
traefik:
image: traefik:v3
container_name: traefik
restart: unless-stopped
command:
# only containers labelled traefik.enable=true are published
- "--providers.docker.exposedByDefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.web.http.redirections.entryPoint.to=websecure"
- "--entryPoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=you@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
volumes:
wg-access-server-data:
Replace vpn.example.com and the email address with yours; the name must point to the server.
WG_TRUSTED_PROXIES is worth setting for any proxy, not only Traefik. Without it every request
appears to come from the proxy, so the sign-in log, the audit trail and the list of sessions people
see of their own all name one address for everybody - which makes them useless for the question they
exist to answer. With it, X-Forwarded-For is believed, but only on requests that really arrive from
one of the networks named: a client that can reach the server directly cannot choose the address it
is logged under. Use the network the proxy speaks to this container on - docker network inspect
prints it - rather than a wider one than needed. The login throttle is keyed by username either way,
so no header can skip it.
Behind Traefik, with a self-signed certificate ¶
The same without Let's Encrypt, for a test or a network where the certificate is not needed:
services:
wg-access-server:
image: ghcr.io/freifunkmuc/wg-access-server:latest
container_name: wg-access-server
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv6.conf.all.forwarding: 1
volumes:
- "wg-access-server-data:/data"
environment:
WG_ADMIN_PASSWORD: "${WG_ADMIN_PASSWORD:?set WG_ADMIN_PASSWORD, the password of the admin account}"
WG_WIREGUARD_PRIVATE_KEY: "${WG_WIREGUARD_PRIVATE_KEY:?set WG_WIREGUARD_PRIVATE_KEY, e.g. to the output of wg genkey}"
WG_HTTPS_ENABLED: "false" # Traefik terminates TLS
ports:
- "51820:51820/udp"
devices:
- "/dev/net/tun:/dev/net/tun"
labels:
- "traefik.enable=true"
- "traefik.http.routers.vpn.rule=Host(`vpn.example.com`)"
- "traefik.http.routers.vpn.entrypoints=websecure"
- "traefik.http.routers.vpn.tls=true"
- "traefik.http.services.vpn.loadbalancer.server.port=8000"
traefik:
image: traefik:v3
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker.exposedByDefault=false"
- "--entryPoints.websecure.address=:443"
ports:
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
volumes:
wg-access-server-data:
For more Traefik options, see https://doc.traefik.io/traefik/https/tls/.
IPv6-only (without IPv4) ¶
In the first example, set WG_VPN_CIDR to 0:
environment:
WG_ADMIN_PASSWORD: "${WG_ADMIN_PASSWORD:?set WG_ADMIN_PASSWORD, the password of the admin account}"
WG_WIREGUARD_PRIVATE_KEY: "${WG_WIREGUARD_PRIVATE_KEY:?set WG_WIREGUARD_PRIVATE_KEY, e.g. to the output of wg genkey}"
WG_VPN_CIDR: "0" # no IPv4
IPv4-only (without IPv6) ¶
In the first example, set WG_VPN_CIDRV6 to 0. The two sysctls for IPv6 are then not needed:
environment:
WG_ADMIN_PASSWORD: "${WG_ADMIN_PASSWORD:?set WG_ADMIN_PASSWORD, the password of the admin account}"
WG_WIREGUARD_PRIVATE_KEY: "${WG_WIREGUARD_PRIVATE_KEY:?set WG_WIREGUARD_PRIVATE_KEY, e.g. to the output of wg genkey}"
WG_VPN_CIDRV6: "0" # no IPv6
With Docker secrets ¶
Instead of passing the admin password and the WireGuard private key as environment
variables, you can mount them as Docker secrets
and point wg-access-server at the files. Environment variables are visible in
docker inspect and to every process in the container; secret files are not.
Create the two files first. wg genkey writes a trailing newline, which
wg-access-server strips when it reads the file:
mkdir -p secrets
wg genkey > secrets/wg_private_key
printf '%s' 'example' > secrets/wg_admin_password
chmod 600 secrets/*
Then reference them through the _FILE variables:
services:
wg-access-server:
image: ghcr.io/freifunkmuc/wg-access-server:latest
container_name: wg-access-server
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv6.conf.all.forwarding: 1
volumes:
- "wg-access-server-data:/data"
environment:
WG_ADMIN_PASSWORD_FILE: "/run/secrets/wg_admin_password"
WG_WIREGUARD_PRIVATE_KEY_FILE: "/run/secrets/wg_private_key"
secrets:
- wg_admin_password
- wg_private_key
ports:
- "8443:8443/tcp"
- "127.0.0.1:8000:8000/tcp"
- "51820:51820/udp"
devices:
- "/dev/net/tun:/dev/net/tun"
secrets:
wg_admin_password:
file: ./secrets/wg_admin_password
wg_private_key:
file: ./secrets/wg_private_key
volumes:
wg-access-server-data:
A _FILE variable and its direct counterpart are exclusive: if both
WG_ADMIN_PASSWORD and WG_ADMIN_PASSWORD_FILE are set - including adminPassword
in a config file - the server refuses to start instead of guessing which one you meant.