I wanted to run two separate Money4Band projects on one Raspberry Pi 5, with each project using a different internet connection. The goal was not simply to connect the Pi to two Wi-Fi networks. I wanted traffic originating from the first Docker stack to leave through one wireless adapter, while traffic from the second stack left through the other adapter.
That distinction matters. A Linux computer can have two Wi-Fi interfaces connected at the same time, but that does not automatically mean Docker containers will use the interface I intend. The host’s normal routing table still makes routing decisions, and without additional configuration, traffic from both Docker projects can leave through the same default route.
My solution evolved into a combination of two Docker Compose projects, two dedicated Docker bridge subnets, NetworkManager profiles bound to individual Wi-Fi adapters, and Linux source-based policy routing. I also wrote Bash functions to restore the configuration and start or stop both projects with short commands.
This article documents the setup and troubleshooting process from my Raspberry Pi conversations and saved configuration files. It is based on the configuration I worked with, not a universal installation script. Interface names, IP addresses, image support, and resource limits will differ on other systems. It assumes Docker Engine and the Compose plugin are already installed; the saved conversation records do not preserve one complete, verified Docker installation sequence, so I have not invented one here.
1. Raspberry Pi hardware and operating system
The project uses a Raspberry Pi 5 with 4 GB of RAM. The operating system reported itself as Debian GNU/Linux 13 (Trixie), running on the 64-bit ARM architecture.
The system information recorded during setup included:
| Component | Recorded configuration |
|---|---|
| Board | Raspberry Pi 5, 4 GB RAM |
| Operating system | Debian GNU/Linux 13 (Trixie) / Raspberry Pi OS |
| Kernel | 6.18.50+rpt-rpi-2712 |
| CPU architecture | aarch64 |
| Docker architecture variable | arm64 |
| Cgroups | Version 2 |
| Cgroup driver | systemd |
The Pi has two wireless interfaces available. wlan0 is the built-in wireless adapter, while wlan1 is a separate USB Wi-Fi adapter. The USB adapter was identified as a Realtek 802.11be wireless adapter.
I chose to use the two adapters for different networks rather than allowing both Docker projects to share one Wi-Fi connection. In my setup, the built-in adapter was assigned to the primary connection and the USB adapter to the secondary connection.
Before building the Docker routing rules, I checked that Linux could see both interfaces and that NetworkManager considered both connected:
nmcli device status
ip addr show wlan0
ip addr show wlan1
The interfaces had separate IPv4 addresses. In one diagnostic snapshot, wlan0 had 192.168.18.51/24 and wlan1 had 192.168.1.129/24. The gateways were 192.168.18.1 and 192.168.1.254, respectively. These are private LAN addresses from my network at the time; your addresses and gateways will be different.
2. Confirming the two wireless adapters
The first step was to make sure both interfaces were up and scan for networks using each adapter individually.
sudo ip link set wlan0 up
sudo ip link set wlan1 up
sudo nmcli radio wifi on
# Scan for networks visible to the built-in adapter.
sudo nmcli device wifi list ifname wlan0
# Scan for networks visible to the USB adapter.
sudo nmcli device wifi list ifname wlan1
Using ifname is useful because it asks NetworkManager to scan through the specified adapter. That helps confirm that each Wi-Fi network is visible to the interface that will eventually use it.
I then created or reused saved NetworkManager connection profiles. The important setting was binding each connection profile to its intended interface. The relevant NetworkManager properties included:
connection.interface-name: binds a saved profile towlan0orwlan1.connection.autoconnect yes: automatically reconnects the profile.connection.autoconnect-priority 100: gives the intended profile a high autoconnect priority.802-11-wireless.ssid: identifies the network for that profile.- WPA-PSK security settings: used for the secured Wi-Fi networks.
802-11-wireless-security.psk-flags 0: tells NetworkManager to store the PSK in its system connection profile rather than asking for it every time.
For a new machine, I recommend entering passwords interactively rather than putting them directly into a script. For example:
sudo nmcli --ask device wifi connect "PRIMARY_WIFI_SSID" ifname wlan0
sudo nmcli --ask device wifi connect "SECONDARY_WIFI_SSID" ifname wlan1
After creating the profiles, I checked them with:
nmcli connection show
nmcli device status
The helper I wrote finds an existing Wi-Fi profile by SSID, prefers a profile already bound to the desired interface, and then connects that saved profile to the correct adapter. It also disables autoconnect on other saved Wi-Fi profiles without deleting them. That prevents NetworkManager from automatically choosing an unrelated saved network and moving one of the adapters away from its assigned connection.
The important point is that wlan0 and wlan1 each have a persistent, separate NetworkManager profile. The configuration does not depend on manually reconnecting to the right network every time the Pi reboots.
3. Why I used two Docker Compose projects
Instead of putting every service into one large Compose project, I created two project directories under my home directory:
/home/<user>/
├── money4band/
│ ├── .env
│ ├── docker-compose.yaml
│ └── application data and configuration
│
└── money4band2/
├── .env
├── docker-compose.yaml
└── application data and configuration
The first directory is the original Money4Band project. The second directory is a separate copy/configuration for the second connection. Each directory has its own .env file and its own Compose project name.
The environment files use different values for the settings that must not collide:
| Setting | First project | Second project |
|---|---|---|
| Compose project name | money4band | money4band2 |
| Docker network driver | bridge | bridge |
| Docker subnet | 172.19.7.0/27 | 172.20.7.0/27 |
| Device name | Unique first-stack name | Unique second-stack name |
| Watchtower scope | money4band | money4band2 |
| Host dashboard port | 8081 in the saved snapshot | Must be different if both dashboards publish a port |
The project name keeps Compose resources separate. The device name helps give containers distinct names, and the Watchtower scope keeps each updater focused on the containers belonging to its own project.
The saved environment snapshots showed M4B_DASHBOARD_PORT=8081 for both projects. If both dashboard containers publish a host port at the same time, they cannot both bind the same host port. One project needs a different port, such as 8082, and the corresponding dashboard URL must use that port. This is an important setting to check when duplicating the stack.
Each .env file also contains account credentials, service tokens, UUIDs, and API keys for the services. Those values are intentionally not reproduced in this article. Keep secrets in local environment files, restrict access to those files, and do not commit them to a public Git repository or paste them into a public tutorial.
4. Configure each Docker bridge network with its own subnet
The final configuration uses Docker’s bridge network driver. Each Compose project creates its own default bridge network, with a separate IP address range.
The first project’s environment includes:
COMPOSE_PROJECT_NAME=money4band
NETWORK_DRIVER=bridge
NETWORK_SUBNET=172.19.7.0
NETWORK_NETMASK=27
DETECTED_ARCHITECTURE=aarch64
DETECTED_DOCKER_ARCH=arm64
M4B_WATCHTOWER_SCOPE=money4band
The second project’s corresponding values include:
COMPOSE_PROJECT_NAME=money4band2
NETWORK_DRIVER=bridge
NETWORK_SUBNET=172.20.7.0
NETWORK_NETMASK=27
DETECTED_ARCHITECTURE=aarch64
DETECTED_DOCKER_ARCH=arm64
M4B_WATCHTOWER_SCOPE=money4band2
The Compose file defines the default network using those environment variables:
networks:
default:
driver: ${NETWORK_DRIVER}
ipam:
config:
- subnet: ${NETWORK_SUBNET}/${NETWORK_NETMASK}
This results in two distinct Docker bridge networks:
- First project:
172.19.7.0/27 - Second project:
172.20.7.0/27
The host gets a bridge-side address for each network. In the recorded configuration, the gateways were 172.19.7.1 and 172.20.7.1. The containers in the first project use source addresses from the first subnet, while containers in the second project use addresses from the second subnet.
This separation is the key that allows the Linux host to recognize which project originated a packet. The next step is to use those source ranges in policy-routing rules.
5. Why a Docker bridge network does not automatically select a Wi-Fi adapter
A Docker bridge network isolates containers into a virtual network on the Pi. It does not, by itself, mean that the network is physically attached to wlan0 or wlan1.
Initially, I investigated using Docker macvlan networks with each wireless adapter as the parent interface. That was an early approach, but it was not the final configuration recorded in my Compose files. The implemented design uses normal Docker bridge networks and tells the Linux host which gateway to use for traffic from each Docker subnet.
This distinction is important:
- Docker bridge networking creates the separate container networks.
- Linux policy routing decides which physical Wi-Fi interface forwards packets from each Docker subnet.
- NetworkManager maintains the two wireless connections and their IP addresses.
The Docker subnet is therefore the routing identity for each stack. The policy rules do not try to attach a Compose project directly to a Wi-Fi device. Instead, they match the source IP range assigned to the project’s bridge network.
6. Creating separate Linux routing tables
The Raspberry Pi has two active Wi-Fi connections, which means the host’s main routing table can contain two default routes. In my diagnostic output, the primary route through wlan0 had metric 600, and the secondary route through wlan1 had metric 601.
A normal host-originated connection will generally follow the main routing table’s route selection. That is not enough for this project because I wanted container traffic from one subnet to use wlan0 and container traffic from the other subnet to use wlan1.
Linux policy routing solves this by allowing rules to select a different routing table according to the packet’s source address.
I used these assignments:
| Docker source subnet | Physical interface | Routing table | Rule priority |
|---|---|---|---|
172.19.7.0/27 | wlan0 | 100 | 1000 |
172.20.7.0/27 | wlan1 | 101 | 1001 |
The first table contains a connected route for the Wi-Fi network on wlan0, plus a default route through its gateway. The second table does the same for wlan1.
The routing table entries are built from live NetworkManager values rather than assuming the gateway will always be the same. The helper reads the current IPv4 address and gateway from the interface and finds its connected route in the main table.
The essential commands for the first route table look like this:
sudo ip -4 route replace 192.168.18.0/24 \
dev wlan0 scope link src 192.168.18.51 table 100
sudo ip -4 route replace default \
via 192.168.18.1 dev wlan0 src 192.168.18.51 metric 10 table 100
The equivalent entries for the second interface use its own connected network, IP address, gateway, and table number:
sudo ip -4 route replace 192.168.1.0/24 \
dev wlan1 scope link src 192.168.1.129 table 101
sudo ip -4 route replace default \
via 192.168.1.254 dev wlan1 src 192.168.1.129 metric 10 table 101
Those addresses are from my recorded network state and are examples for explaining the table layout. A reusable script should discover them dynamically rather than hardcode them.
I also added an unreachable default route with metric 42760 to each custom table:
sudo ip -4 route replace unreachable default metric 42760 table 100
sudo ip -4 route replace unreachable default metric 42760 table 101
This is a fail-closed measure. If the preferred route in a table disappears, traffic matching that table should not quietly fall through to the other Wi-Fi connection. It is intended to prevent the two Docker projects from silently swapping egress paths during a connection failure.
7. Source-based policy rules
Once the routing tables are populated, policy rules connect each Docker subnet to its matching table:
sudo ip -4 rule add priority 1000 \
from 172.19.7.0/27 lookup 100
sudo ip -4 rule add priority 1001 \
from 172.20.7.0/27 lookup 101
The first rule means that packets whose source address belongs to 172.19.7.0/27 should use routing table 100. The second rule sends packets sourced from 172.20.7.0/27 to table 101.
The priority values determine where these rules are evaluated relative to other policy-routing rules. The normal built-in rules commonly include the local table at priority 0, the main table at 32766, and the default table at 32767. My custom rules use priorities 1000 and 1001, so they are evaluated before the normal main-table rule.
The helper removes previous copies of its own specific rule before adding a new one. That makes repeated runs less likely to accumulate duplicate rules.
8. The Bash functions that manage the setup
I did not want to manually reconnect both Wi-Fi adapters, rebuild both routing tables, and start each Compose project every time I restarted the Raspberry Pi. I put the reusable functions into a separate file:
~/.money4band-functions
Then I added a small loader to ~/.bashrc:
# Load Money4Band management commands in Bash.
[ -f "$HOME/.money4band-functions" ] && source "$HOME/.money4band-functions"
The installer script created the functions file, added the loader to .bashrc if it was not already present, and sourced .bashrc so the functions became available in the current terminal. The installer also set the functions file to permission mode 600 because an earlier version contained local Wi-Fi credentials. For a public tutorial or a cleaner implementation, I would keep passwords out of shell scripts and use NetworkManager’s saved connection profiles or interactive prompts instead.
These commands are technically Bash functions rather than Bash aliases. They behave like short commands at the prompt, but each is a function containing multiple operations.
_m4b_wifi_uuid_for_ssid
This helper searches saved NetworkManager profiles for a matching SSID. It prefers the profile already bound to the requested interface and returns its connection UUID. That UUID is then used to bring the correct profile up on the correct adapter.
_m4b_apply_route
This helper receives four parameters:
interface, Docker subnet, routing-table ID, rule priority
For example:
_m4b_apply_route wlan0 172.19.7.0/27 100 1000
_m4b_apply_route wlan1 172.20.7.0/27 101 1001
It waits up to 30 seconds for NetworkManager to report an IPv4 address and gateway and for the main table to contain a connected route for that interface. It then rebuilds the dedicated routing table, installs the connected and default routes, adds the unreachable fallback, removes duplicate copies of the matching policy rule, and adds one current rule.
dockernetwork
This is the network restoration command. Its job is to:
- Request sudo authentication with
sudo -v. - Turn Wi-Fi on through NetworkManager.
- Find or create the saved profile for the first Wi-Fi adapter.
- Find or create the saved profile for the second Wi-Fi adapter.
- Bind each profile to its intended interface and enable autoconnect.
- Disable autoconnect for other saved Wi-Fi profiles without deleting them.
- Bring the correct saved profiles up on
wlan0andwlan1. - Apply the two Docker-subnet routing tables and policy rules.
- Print the interface status, route tables, and policy rules for inspection.
dockerup
The dockerup function manages both projects. It first looks for a supported Compose filename in each project directory (docker-compose.yaml, compose.yaml, docker-compose.yml, or compose.yml). It validates both configurations with docker compose config --quiet, runs dockernetwork, and then starts both projects with docker compose up -d.
The important part is that the network restoration runs before the containers are started:
sudo docker compose -f "$compose1" config --quiet
sudo docker compose -f "$compose2" config --quiet
dockernetwork || return 1
sudo docker compose -f "$compose1" up -d
sudo docker compose -f "$compose2" up -d
At the end, the function prints a table of running containers, status, and attached Docker networks.
dockerdown
The dockerdown function finds both Compose files and runs docker compose down against each project. It records the exit status from each command so it can attempt to stop the second project even if shutting down the first one reports an error.
The four commands I wanted available at the shell were therefore:
dockernetwork
dockerup
dockerdown
The underscore-prefixed helpers are implementation details used by those commands.
9. What the Money4Band Compose configuration manages
The Compose files describe multiple services, each with its own container name, restart behavior, logging configuration, resource limits, and Watchtower labels. The saved second-stack Compose revision included services such as Honeygain, IPRoyal Pawns, TraffMonetizer, Repocket, EarnFM, ProxyRack, Bitping, Gradient, Proxybase, Watchtower, and the Money4Band web dashboard. Other iterations of the project also included additional services and experiments.
Several services use restart policies such as always or unless-stopped, so Docker can restart them after a container or host restart. The Compose files also use JSON-file log rotation settings such as:
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
This keeps a container from growing its JSON log file indefinitely. The actual resource settings are read from .env variables and applied using Compose properties such as cpus, mem_reservation, and mem_limit.
Watchtower is configured with labels and a per-project scope. The saved settings included a polling interval of 14400 seconds (four hours), rolling restarts, cleanup of replaced images, and access to the Docker socket:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
The Docker socket gives Watchtower access to the Docker API, so it should be treated as a privileged component. The scope and labels help keep the updater focused on the intended project rather than indiscriminately updating every container on the Pi.
Pay attention to RAM limits on a 4 GB Pi
The saved Raspberry Pi environment file used RAM_CAP_MB_DEFAULT=1536 and memory limits ranging from 614m to 1536m for its resource classes. A later saved environment file for the second project contained a 6144 MB RAM cap and higher per-class limits. That later value is larger than the Pi’s 4 GB of physical memory and should not be copied as a safe recommendation for this hardware.
Container memory limits are ceilings, not a promise that memory is allocated immediately, but several containers can still compete for the same physical RAM. I would review both .env files together and monitor actual use with:
sudo docker stats
free -h
The Pi has to run the operating system, Docker, networking, and every container. Leave memory for the host and avoid assigning limits that assume the board has more RAM than it physically contains.
10. ARM64 images and AMD64 compatibility
The Raspberry Pi 5 runs a 64-bit ARM operating system, and Docker identifies the architecture as arm64. Not every image in a multi-service stack necessarily publishes an ARM64 build.
The Compose files I worked with included a mixture of native ARM-oriented images and services explicitly configured with platform: linux/amd64. For example, the saved second-stack Compose revision specified AMD64 for some services and ARM64 for others. I also installed binfmt emulation support and checked that the host reported additional supported platforms.
That allowed experimentation with images that were not available as native ARM64 images, but it does not make AMD64 emulation identical to running a native ARM64 image. Emulation can add overhead, and some images or programs may still fail. Whenever a provider publishes a suitable ARM64 image, native ARM64 is generally the better choice on this hardware.
I also experimented with EarnApp in a container, then worked with the native Linux EarnApp client as a separate system service. That native installation is distinct from the Docker Compose stacks and should not be assumed to be managed by the dockerup function.
11. Verifying that the two stacks use different routes
After running dockernetwork or dockerup, I check the network state instead of assuming the script worked.
First, confirm the wireless profiles:
nmcli -f DEVICE,TYPE,STATE,CONNECTION device status
Then inspect the custom policy rules and route tables:
echo "========== POLICY RULES =========="
ip -4 rule show
echo "========== ROUTING TABLE 100 =========="
ip -4 route show table 100
echo "========== ROUTING TABLE 101 =========="
ip -4 route show table 101
The expected policy is:
- Source addresses from
172.19.7.0/27use table100, whose default route exitswlan0. - Source addresses from
172.20.7.0/27use table101, whose default route exitswlan1.
The route lookup can be tested directly with example container source addresses:
ip -4 route get 1.1.1.1 from 172.19.7.2
ip -4 route get 1.1.1.1 from 172.20.7.2
The first lookup should resolve through the route table for wlan0; the second should resolve through the route table for wlan1. These are host-side route-decision tests. For an end-to-end check, run a public-IP check from a container in each stack using an HTTP client that is installed in that container, and compare the results.
It is also possible to watch packets on each interface:
sudo tcpdump -ni wlan0
sudo tcpdump -ni wlan1
Run the captures in separate terminals while generating traffic from the corresponding stack. This helps confirm which interface is carrying the traffic.
12. Troubleshooting: FIB table does not exist
One issue I encountered was this error when running dockernetwork:
Configuring wlan0 / Docker subnet 172.19.7.0/27...
Error: ipv4: FIB table does not exist.
Flush terminated
The first version of _m4b_apply_route tried to flush the numbered routing table before creating any routes in it:
sudo ip -4 route flush table "$table" || return 1
On a fresh boot or when the table had never been populated, the kernel reported that the FIB table did not exist. Because the function used || return 1, it stopped there and never reached the second interface.
The diagnostic output also showed only the standard rules and no custom policy rules yet. I checked /etc/iproute2/rt_tables, but that file was not present on this Raspberry Pi. The important point is that the helper was using numeric table IDs (100 and 101); the missing text file was not itself the cause of the failed flush.
The corrective approach discussed was to check whether the table already contains routes and flush it only when there is something to remove:
existing_routes=$(sudo ip -4 route show table "$table" 2>/dev/null || true)
if [[ -n "$existing_routes" ]]; then
sudo ip -4 route flush table "$table" || return 1
fi
The helper can then add the connected route, default route, unreachable fallback, and policy rule. This avoids treating an empty, not-yet-created table as a fatal error. After making the change, the updated function should also be saved back into ~/.money4band-functions; replacing a function only in the current shell does not make that change survive a new terminal session or reboot.
13. The final design at a glance
The complete routing layout is easier to understand as a mapping between the two Compose projects and the two physical Wi-Fi interfaces:
| Layer | First Money4Band project | Second Money4Band project |
|---|---|---|
| Project directory | ~/money4band | ~/money4band2 |
| Compose project name | money4band | money4band2 |
| Docker network | money4band_default | money4band2_default |
| Docker subnet | 172.19.7.0/27 | 172.20.7.0/27 |
| Policy rule | Priority 1000 | Priority 1001 |
| Routing table | 100 | 101 |
| Wi-Fi device | wlan0 | wlan1 |
| Egress path | Primary Wi-Fi | Secondary Wi-Fi |
I also discussed using a systemd service or timer to reapply the network setup periodically, but I could not verify a final installed unit file in the saved records. For that reason, the commands documented here are the confirmed Bash-function workflow rather than a claim that a recurring timer is active.
The overall process is:
- NetworkManager connects each wireless adapter to its assigned saved profile.
- Docker Compose creates a separate bridge network for each project.
- Containers receive addresses from the subnet assigned to their project.
- Linux policy rules match each packet’s source subnet.
- The matching routing table sends traffic through the correct Wi-Fi gateway.
- Bash functions reapply the network configuration and manage both Compose projects.
This arrangement gives me two independent Money4Band Compose projects on one Raspberry Pi 5, with a clear division between their Docker networks and their outbound Wi-Fi paths. The most important part was not simply creating two Docker networks; it was connecting those network subnets to separate Linux policy-routing tables and making the setup repeatable through NetworkManager and Bash functions.
Conclusion
This project became more involved than starting a single Docker Compose file. I had to account for the Raspberry Pi’s ARM64 architecture, two wireless adapters, NetworkManager connection profiles, separate Docker subnets, Linux routing tables, policy-rule priorities, container resource limits, and image compatibility.
The result is a setup where ~/money4band is associated with the first Docker subnet and wlan0, while ~/money4band2 uses a different subnet and wlan1. The dockernetwork, dockerup, and dockerdown Bash functions make the configuration easier to restore and operate without manually repeating every command.
The biggest lesson is that Docker network separation and physical network-interface selection are two different jobs. Docker creates the isolated bridge networks; Linux policy routing determines which physical adapter carries their outbound traffic. Once those layers are configured deliberately, it becomes much easier to understand, test, and maintain a multi-network Docker setup on a small ARM computer.