WireGuard VPN gateway inside a Docker container connected to persistent configuration storage and two client devices
← Back
wireguard

WireGuard Server with Docker Compose: Secure 2026 Guide

enim · Aug 29, 2026 · 6 min read · Updated: Aug 29, 2026
TL;DR: Pin the LinuxServer WireGuard image, bind /config to a protected host directory, grant only the networking capabilities it needs, keep QR configurations out of logs, add an interface health check, and back up the persistent keys before every upgrade.

Running a WireGuard server with Docker Compose makes peer generation, upgrades, and recovery repeatable. It does not put the WireGuard kernel module inside the image: the host kernel still provides the tunnel, while the container receives capabilities to configure it.

This is the container-specific guide. For the smallest dependency chain and direct routing control, use the native Ubuntu WireGuard server guide. Docker changes the operational boundary, not the protocol.

What the tested container costs

I tested the pinned image on ByteGuard's Hetzner CPX22 on 29 August 2026. The host ran kernel 6.8.0-138-generic, Docker 29.7.2, and Docker Compose 5.5.0.

lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls120 created its interface and one peer in 1.93 seconds, used 19.43 MiB RAM after startup, occupied 40.66 MB locally, and generated 12 persistent configuration files. I stopped and removed the benchmark container afterward while retaining its protected evidence directory. These are one-host measurements, not universal performance claims.

Docker Compose versus native WireGuard

Concern Docker Compose Native wg-quick
Peer generation Automatic from variables Manual
Key persistence Bind-mounted /config /etc/wireguard
Rollback Revert tag and reuse /config Reinstall or restore files
Kernel dependency Host kernel Host kernel
Privilege NET_ADMIN; sometimes SYS_MODULE Root/systemd service
Host migration Copy Compose plus encrypted /config Reproduce packages, firewall, and config

Docker is not a hard sandbox here. NET_ADMIN is powerful, and mounting /lib/modules exposes host modules. If that tradeoff is uncomfortable, use the bare-metal setup.

Prerequisites

Confirm that the host loads the required modules:

sudo modprobe wireguard
sudo modprobe ip_tables
lsmod | grep -E 'wireguard|ip_tables'

When the host preloads everything, SYS_MODULE and /lib/modules can often be removed. The LinuxServer documentation retains them as optional compatibility settings.

Create persistent storage

sudo install -d -m 0700 -o "$USER" -g "$USER" /opt/wireguard/config
cd /opt/wireguard

The /config mount stores server and peer private keys, QR images, templates, and wg0.conf. Treat it as a credential vault. Recreating a container must never recreate every identity.

Create compose.yaml

services:
  wireguard:
    image: lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls120
    container_name: wireguard
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    environment:
      PUID: "1000"
      PGID: "1000"
      TZ: "Africa/Casablanca"
      SERVERURL: "vpn.example.com"
      SERVERPORT: "51820"
      PEERS: "laptop,phone"
      PEERDNS: "auto"
      INTERNAL_SUBNET: "10.13.13.0"
      ALLOWEDIPS: "0.0.0.0/0"
      LOG_CONFS: "false"
    volumes:
      - ./config:/config
      - /lib/modules:/lib/modules:ro
    ports:
      - "51820:51820/udp"
    sysctls:
      net.ipv4.conf.all.src_valid_mark: "1"
    healthcheck:
      test: ["CMD-SHELL", "wg show wg0 >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

Replace the domain, timezone, UID, and GID. Get IDs with id -u and id -g. Peer names must be alphanumeric.

The settings are intentional:

  • The exact image tag makes upgrades reviewable and reversible.
  • NET_ADMIN lets the container create wg0, routes, and firewall rules.
  • SYS_MODULE and /lib/modules are needed only if the container loads host modules.
  • /config persists keys across recreation.
  • LOG_CONFS=false prevents private QR configurations from reaching routine logs.
  • The health check proves wg0 exists; a running PID alone does not.

LinuxServer supports a read-only filesystem in server mode, but test its required temporary mounts and upgrade behavior on your pinned image before adding read_only: true. Read-only mode is not supported when the image runs as a WireGuard client.

Validate and start

docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 80 wireguard
docker exec wireguard wg show
docker inspect --format '{{json .State.Health}}' wireguard

Do not paste full logs into a public issue. Even with QR logging disabled, domains and peer names expose network details.

Open the UDP port in UFW and the provider firewall:

sudo ufw allow 51820/udp comment 'WireGuard'
sudo ufw status numbered

Retrieve client configurations safely

find config -maxdepth 2 -type f -name '*.conf' -print

The laptop file normally appears at config/peer_laptop/peer_laptop.conf. Transfer it through an authenticated channel, import it into the official client, and delete temporary copies.

Display only the phone peer's QR code in a private terminal:

docker exec -it wireguard /app/show-peer phone

The QR code contains a private key. Never turn LOG_CONFS on merely for convenience when logs are forwarded elsewhere.

Full tunnel and split tunnel settings

For an IPv4 full tunnel:

ALLOWEDIPS: "0.0.0.0/0"

For a split tunnel to WireGuard and a private application subnet:

ALLOWEDIPS: "10.13.13.0/24,10.20.0.0/16"

LinuxServer's default includes IPv4 and IPv6 default routes when the variable is omitted. Do not send ::/0 to peers until the host has working IPv6 forwarding plus a routed prefix or intentional NAT66; otherwise clients may black-hole IPv6.

Split tunnels are usually right for private administration of Nginx Proxy Manager, n8n, Vaultwarden, or Uptime Kuma. A full tunnel makes the VPS the internet exit.

Add and revoke peers

Append a peer name, then recreate the service:

PEERS: "laptop,phone,tablet"
docker compose up -d

LinuxServer regenerates configurations while keeping existing peer keys in /config. Renaming is not revocation. For a lost device, remove it from the server configuration and delete its saved keys only after making a verified backup.

Advanced site-to-site deployments can use SERVER_ALLOWEDIPS_PEER_<name> for extra subnets, but generated templates and return routes still require review. The native guide is clearer for a hand-built routed network.

Back up keys and configuration

Back up before any image or environment change:

sudo install -d -m 0700 /var/backups/wireguard
sudo tar --zstd -C /opt/wireguard \
  -cpf "/var/backups/wireguard/wireguard-$(date +%F-%H%M%S).tar.zst" \
  compose.yaml config
sudo chmod 600 /var/backups/wireguard/wireguard-*.tar.zst

This archive is plaintext secret material. Encrypt it before copying it off-host, enforce retention, and test it without restoring over production:

sudo tar --zstd -tf /var/backups/wireguard/wireguard-YYYY-MM-DD-HHMMSS.tar.zst

The Restic and Backblaze guide covers encrypted off-site storage. A useful backup includes config/wg_confs/wg0.conf and every required peer directory.

Upgrade without rotating keys

Read the changelog, test a backup, change the pinned tag, then:

docker compose pull wireguard
docker compose up -d wireguard
docker compose ps
docker exec wireguard wg show

Test a real handshake and both private and internet routes. On failure, restore the previous tag and recreate the service without deleting /config. Upstream recommends recreation for updates and discourages unattended auto-updaters; review its release list before moving the pin.

Troubleshooting

Symptom Check Fix
Exits on startup docker compose logs --tail 100 wireguard Load host modules; add optional module access only when needed
Running but unhealthy docker exec wireguard wg show wg0 Fix variables, sysctl, or an invalid file in config/wg_confs
No handshake sudo tcpdump -ni any udp port 51820 Correct SERVERURL, publish UDP, and open both firewalls
Handshake, no internet Inspect container NAT rules and host forwarding Restore NAT and ensure forwarding is permitted
Existing clients changed Inspect the /config mount Restore the persistent backup; never launch on an empty replacement mount
QR appears in logs Check LOG_CONFS Set false, recreate, and treat exposed configurations as compromised
Split LAN unreachable Compare routes and allowed subnets Add exact subnets plus a return route or intentional NAT
Portainer behaves differently Compare generated Compose configuration Deploy with Compose; upstream warns some Portainer versions mishandle caps/sysctls

Security checklist

  • Pin a reviewed image and record the rollback tag.
  • Protect /opt/wireguard/config and backups with 0700/0600 permissions.
  • Keep QR configurations out of logs and screenshots.
  • Remove SYS_MODULE and /lib/modules when host-preload testing succeeds.
  • Expose only UDP 51820; there is no admin dashboard.
  • Give each device a unique key and revoke lost devices.
  • Monitor interface health and handshakes, not only container uptime.
  • Isolate adjacent workloads using the advanced container security guide.

Conclusion

The container is replaceable; /config is not. Pin the image, protect the bind mount, grant network capabilities consciously, keep QR data out of logs, and prove every upgrade with a real handshake.

Use Compose when repeatable deployment and rollback are the priority. Use the native WireGuard server setup for fewer layers and direct routing control. Start with one client and IPv4, then add split networks, IPv6, and more peers only after the baseline works.

enim

Security researcher, CTF player, and compulsive self-hoster. Building byte-guard.net from a $10/mo Hetzner VPS. Everything I publish I have actually run in production.

Comments

Sign in with GitHub to comment. Threads live in the byteguard-comments repo.