Routing Specific Docker Containers Through WireGuard VPN with systemd-networkd

I recently reorganized my self-hosted stuff to use Docker. While Docker not really fits my philosophy, the broad availability and low-maintenance of images for pretty much all software convinced me to switch and so far I’m happy, it’s significantly less work than before, I can check the Docker Compose files into version control, and backups are easy with everthing inside Docker volumes.
The Problem
Anyway – here is the scenario I want to talk about: You have one or more Docker containers and you want to route all its traffic through a WireGuard VPN, but not the other containers’ or the host’s traffic. You have root access to the host machine.
The Way to the Solution
wg-quick
The most straightforward way of using WireGuard is wg-quick.
You just need a configuration file, about 10 lines long (take a look at
an OpenVPN config file and you will appreciate this shortness), run
sudo wg-quick up {config file}
and your VPN is up and
running. These files also work with the Android/iOS/MacOS/Windows
apps.
For example, the VPN provider Mullvad, which I can recommend 100%, lets you download wg-quick files for easy setup.
wg-quick is easy, but it routes all traffic through the VPN, which is what you want most of the times, but not in our use case. Watch out, the allowed IP range does not help as you might think: You can tell WireGuard that only traffic to specific IPs should be routed through the VPN, which makes sense for something like a VPN for employees: only traffic to the company’s network should go through the VPN. We however need to filter by source. wg-quick can’t do that.
Using the Tools directly
After quite a lot of searching I finally found a great blog
article detailing a solution to our exact problem using the
wg
and ip
tools directly (and one using
WireGuard client inside another container). This article is mostly based
on that one.
The gist of that that method is: You set up a WireGuard interface
manually, the same way wg-quick does internally, but without any routing
to it yet. Then you add a routing rule via ip
that sends
all traffic from a specific subnet to the VPN. Lastly, you configure the
desired Docker container to use exactly that subnet using Docker Compose
or docker network
.
While it is a nice and elegant solution, I think it is kind of cumbersome to configure, so I tried to find a more comfortable way of setting this up.
systemd-networkd
While I agree with some of the criticism against systemd and its
policies, systemd-networkd really is the best thing that ever happened
to network configuration on Linux. Instead of fiddling around with
awfully complex tools like ip
or weird network managers,
you can set up your network with short, few and well-documented
plain-text config files. I love it. Turns out it also has everything we
need for tunneling our Docker containers, and in a nice and easy way.
This is the solution I went with and want to show you.
Instructions in Short
For the impatient. For detailed instructions see below.
To tunnel a container through a WireGuard VPN given a wg-quick config
file from your VPN provider, add these files to
/etc/systemd/network/
:
80-wg0.netdev
:
[NetDev]
Name = wg0
Kind = wireguard
Description = WireGuard VPN
[WireGuard]
PrivateKey = {Private key, same as in wg-quick config}
RouteTable = off
[WireGuardPeer]
PublicKey = {Public key, same as in wg-quick config}
AllowedIPs = 0.0.0.0/0,::0/0
Endpoint= {Endpoint, same as in wg-quick config}
85-wg0.network
:
[Match]
Name=wg0
[Network]
# If you need multiple addresses, e.g. for IPv4 and 6, use multiple Address lines.
Address = {Address to bind to inside the VPN, same as in wg-quick config}
[RoutingPolicyRule]
From = 10.123.0.0/16
Table = 242
[Route]
Gateway = {The address of the interface, same as above in [Network] in Address}
Table = 242
[Route]
Destination = 0.0.0.0/0
Type = blackhole
Metric = 1
Table = 242
Then run
sudo docker network create tunneled0 --subnet 10.123.0.0
.
Now you can run docker containers with --net=tunneled0
to
tunnel them.
Alternatively use Docker Compose to create and use a Docker network in that subnet:
version: "3.7"
services:
app:
image: {image}
dns: "{DNS server to use}"
networks:
tunneled0: {}
networks:
tunneled0:
ipam:
config:
- subnet: 10.123.0.0/16
That’s it!
The Detailed Solution
Preparation
Make sure that your host has:
- systemd. Most Linuxes do.
- The WireGuard kernel module installed or kernel 5.6 or newer running.
- The WireGuard tools installed.
- Docker and optionally Docker Compose installed.
- A working network connection. I don’t think it needs to be configured using systemd-networkd, though I haven’t tested that. I recommend to use networkd if possible anyway.
- systemd-networkd running and enabled
(
sudo systemctl enable systemd-networkd && systemctl start system-networkd
).
Setting up the Interface
First we have to get the WireGuard interface running. We couldn’t do
it with wg-quick
as it automatically routes all traffic
through it, and using wg
is cumbersome, so we use
systemd-networkd. All we have to do is add two files in
/etc/systemd/network/
:
80-wg0.netdev
:
[NetDev]
# Or any other name
Name = wg0
Kind = wireguard
# Or your own description
Description = WireGuard VPN
[WireGuard]
PrivateKey = {Private key, same as in wg-quick config}
RouteTable = off
[WireGuardPeer]
PublicKey = {Public key, same as in wg-quick config}
# Remeber, these are allowed target IPs, not source, therefore we allow all
AllowedIPs = 0.0.0.0/0,::0/0
Endpoint= {Endpoint, same as in wg-quick config}
85-wg0.network
:
[Match]
# Same as in .netdev file
Name=wg0
[Network]
# If you need multiple addresses, e.g. for IPv4 and 6, use multiple Address lines.
Address = {Address to bind to inside the VPN, same as in wg-quick config}
As you can see, it’s very similar to and just as easy as a wg-quick config file and most values can be taken straight from said file. For more info take a look at the man pages of netdev and network files.
The names of the files can be adjusted to your liking. Note that systemd-networkd reads config files in alphabetic order, so adjust the prefixed numbers in the names if necessary.
Use # systemctl restart systemd-networkd
(or reboot to
be sure) to apply the configs. Now you can verify that the inferface is
actually working:
$ curl -4 icanhazip.com
$ sudo curl -4 --interface wg0 icanhazip.com
The results of the two curl
calls should be different,
the first shows your normal IP, the second one should yield the VPN IP
address. Note that for me the second curl only works as root (probably
curl can only bind to the interface as root for some reason). With
sudo wg
and networkctl status wg0
you can get
further info about the interface.
Routing
Now that we got the WireGuard interface up and running we have to
arrange for the traffic of our Docker container to actually go through
it. Turns out all we have to do is adding a few lines to
85-wg0.network
. This it how it should look like:
Updated 85-wg0.network
:
[Match]
Name=wg0
[Network]
# If you need multiple addresses, e.g. for IPv4 and 6, use multiple Address lines.
Address = {Address to bind to inside the VPN, same as in wg-quick config}
[RoutingPolicyRule]
# Or any other unused private subnet
From = 10.123.0.0/16
# Or any other unused table number
Table = 242
[Route]
Gateway = {The address of the interface, same as above}
# Same table number as above
Table = 242
[Route]
Destination = 0.0.0.0/0
Type = blackhole
Metric = 1
# Same table number as above
Table = 242
What the [RoutingPolicyRule]
section does is taking all
traffic from the specified subnet and looking up the routes in routing
table 242 for it. We add a route to (hopefully previously empty) table
242 with the [Route]
section, and that route sends the
traffic to our WireGuard interface because we set the interface’s
address as gateway.
The second [Route]
section sets a blackhole route in the
same table with a metric of 1, that means a lower priority than the
default metric of 0. This should discard all traffic (instead of routing
it through the default network without any VPN) if the VPN gateway is
down and therefore prevent leaks.
That should be all we have to do on the system side!
Using it with Docker
To actually get Docker to use the interface with specific containers we have two possibilities.
Note for both methods that published ports will not be available on
localhost
on the host as they normally would as all
container traffic goes through the VPN (which is what we wanted, of
course). So if you add an exposed port it must be accessed through the
VPN’s outside address.
Docker Directly
Create a Docker network in the subnet we used in the systemd-networkd
config file with
sudo docker network create tunneled0 --subnet 10.123.0.0/16
(or use any other name than tunneled0
), then run containers
in that network by using the --net=tunneled0
option. With
the --dns
option you can set a custom DNS so that no DNS
traffic gets leaked.
For example, you can use
sudo docker run -t --net=tunneled0 curlimages/curl icanhazip.com
to check that the returned IP is actually the VPN’s IP.
Docker Compose
This is the more comfortable method. You can use this as a base for your own compose files:
version: "3.7"
services:
app:
image: {image}
dns: "{DNS server to use}"
networks:
# Or your own name
tunneled0:
networks:
# Same name as above
tunneled0:
ipam:
config:
- subnet: 10.123.0.0/16
Port Forwarding
You can use Docker’s normal port publishing options to make ports
available through the VPN. So, for example, if your VPN provider gives
you port 1234
and you want port 80
inside your
container to be available through the VPN, call Docker with
-p 1234:80
(do not forget the other required options
explained above) or add
ports:
- "1234:80"
to the corresponding service’s section in the Docker Compose file.
Note that published ports of tunneled containers are not
reachable on localhost
, only through the VPN. Sadly, I
haven’t yet found a possibility to fix that.
Conclusion
We got Docker containers running on a WireGuard VPN with only two short and simple config files. If you have any questions or comments, please post them in the discussion forum or contact me.
A big thank you goes out to Nick Babcock for the great article this one is based on!
Update 1: Added a blackhole route to prevent leaks when VPN gateway is down. Thanks to tchamb for the suggestion!
Update 2: Added a section explaining port forwarding. Thanks to Maren for the idea!
Update 3: This post was posted on Hacker News and even reached the front page for some time! I’m honored!
Update 4: Since systemd version 250 systemd-networkd
creates routes for addresses specified in AllowedIPs
for
WireGuard (see changelog).
This interferes with the route we create manually. This is fixed by
adding RouteTable = off
in the [WireGuard]
section in the .netdev
file. I updated the instructions
accordingly.
Update 5: There was a mistake in the Docker section:
when creating a Docker network via CLI you need to specify a prefix
size, just as you need to in a Docker Compose file. So, instead of
sudo docker network create tunneled0 --subnet 10.123.0.0
you need to run
sudo docker network create tunneled0 --subnet 10.123.0.0/16
.
I fixed it in the article, thanks to Elluvean of Light for the hint!
Image source, licensed under CC-BY-2.0↩︎
Thank you for reading!
Follow me on Mastodon / the Fediverse! I'm @eisfunke@inductive.space.
If you have any comments, feedback or questions about this post, or if you just want to say hi, you can ping me there!