Tunneling Traffic of Containers Through a VPN

In a previous post I described how to route traffic for a single Linux user through a VPN. For containers, the process is much simpler.

To begin, create a docker-compose file (or a Portainer stack). This should include the containers you wish to protect, as well as a container connecting to your VPN. I've chosen to use a WireGuard container provided by the Linuxserver group.

version: "3"
services:
  vpn:
    image: lscr.io/linuxserver/wireguard
    container_name: wireguard
    cpus: 4
    cap_add:
      - NET_ADMIN
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/Berlin
    volumes:
      - /opt/wireguard:/config
      - /lib/modules:/lib/modules
    ports:
    # Add ports here of other containers in the stack which you want to expose
      - 8080:80 # port forward of the whoami testcontainer
    sysctls:
      - net.ipv6.conf.all.disable_ipv6=1  # Recomended if using ipv4 only
      - net.ipv4.conf.all.src_valid_mark=1
    restart: unless-stopped
  testcontainer:
    image: nginx
    network_mode: service:vpn
    depends_on:
      - vpn
    container_name: testcontainer
    restart: always
networks:
    default:
        name: wireguard
        # Use this if you created the network elsewhere
        # external: true

Next, download the WireGuard configuration from your VPN provider. For my setup, I used Mullvad. From the available configurations (usually provided for different locations), select one and save it inside /opt/wireguard/wg_confs as wg0.conf.

Afterwards, modify the wg0.conf file and add the PostUp and PreDown rules provided below. Essentially, these rules ensure that communication with internal networks outside the VPN remains intact. Without them, you wouldn't be able to access the exposed ports of your containers, such as dashboards.

[Interface]
PrivateKey = YourWireGuardKey
Address = 10.64.1.88/32
DNS = 1.1.1.1
PostUp = DROUTE=$(ip route | grep default | awk '{print $3}'); HOMENET=192.168.0.0/16; HOMENET2=10.0.0.0/8; HOMENET3=172.16.0.0/12; ip route add $HOMENET3 via $DROUTE;ip route add $HOMENET2 via $DROUTE; ip route add $HOMENET via $DROUTE;iptables -I OUTPUT -d $HOMENET -j ACCEPT;iptables -A OUTPUT -d $HOMENET2 -j ACCEPT; iptables -A OUTPUT -d $HOMENET3 -j ACCEPT;  iptables -A OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
PreDown = HOMENET=192.168.0.0/16; HOMENET2=10.0.0.0/8; HOMENET3=172.16.0.0/12; ip route del $HOMENET3 via $DROUTE;ip route del $HOMENET2 via $DROUTE; ip route del $HOMENET via $DROUTE; iptables -D OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT; iptables -D OUTPUT -d $HOMENET -j ACCEPT; iptables -D OUTPUT -d $HOMENET2 -j ACCEPT; iptables -D OUTPUT -d $HOMENET3 -j ACCEPT

[Peer]
PublicKey = PublicKeyFromTheDownloadedConf
AllowedIPs = 0.0.0.0/0
Endpoint = TheServerIPofYourVPNProvider

How wg0.conf should look like

I have taken this configuration from Linuxservers' blog.

Note that when using these specific routes in the WireGuard configuration, it's imperative that ports you want to forward for all your containers in the stack are explicitly specified in the VPN container. An example of this is how I specified port 8080 in the configuration above.

To verify your configuration, you can initiate a shell inside the container using:

docker exec -it testcontainer /bin/sh

For those using Portainer, there's a dedicated button for this purpose. Once inside, execute the following commands to test:

curl https://am.i.mullvad.net/connected # If you use mullvad
curl ifconfig.me # to get the outgoing IP from the container

Furthermore, from outside your container, you should be able to run curl 127.0.0.1:8080 to access the container's port.

Conclusion

By following the steps above, you can ensure that your containerized applications communicate securely via a trusted VPN.