{"id":18,"date":"2026-11-08T01:56:26","date_gmt":"2026-11-08T01:56:26","guid":{"rendered":"https:\/\/bassettgraphics.com\/blog\/?p=18"},"modified":"2026-11-08T01:57:44","modified_gmt":"2026-11-08T01:57:44","slug":"how-i-made-money-making-apps-work-with-2-networks-on-a-raspberry-pi","status":"publish","type":"post","link":"https:\/\/bassettgraphics.com\/blog\/2026\/11\/08\/how-i-made-money-making-apps-work-with-2-networks-on-a-raspberry-pi\/","title":{"rendered":"How I made money making apps work with 2 networks on a Raspberry Pi"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s normal routing table still makes routing decisions, and without additional configuration, traffic from both Docker projects can leave through the same default route.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Raspberry Pi hardware and operating system<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The system information recorded during setup included:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Component<\/th><th>Recorded configuration<\/th><\/tr><tr><td>Board<\/td><td>Raspberry Pi 5, 4 GB RAM<\/td><\/tr><tr><td>Operating system<\/td><td>Debian GNU\/Linux 13 (Trixie) \/ Raspberry Pi OS<\/td><\/tr><tr><td>Kernel<\/td><td><code>6.18.50+rpt-rpi-2712<\/code><\/td><\/tr><tr><td>CPU architecture<\/td><td><code>aarch64<\/code><\/td><\/tr><tr><td>Docker architecture variable<\/td><td><code>arm64<\/code><\/td><\/tr><tr><td>Cgroups<\/td><td>Version 2<\/td><\/tr><tr><td>Cgroup driver<\/td><td><code>systemd<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The Pi has two wireless interfaces available. <code>wlan0<\/code> is the built-in wireless adapter, while <code>wlan1<\/code> is a separate USB Wi-Fi adapter. The USB adapter was identified as a Realtek 802.11be wireless adapter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Before building the Docker routing rules, I checked that Linux could see both interfaces and that NetworkManager considered both connected:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nmcli device status\n\nip addr show wlan0\nip addr show wlan1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The interfaces had separate IPv4 addresses. In one diagnostic snapshot, <code>wlan0<\/code> had <code>192.168.18.51\/24<\/code> and <code>wlan1<\/code> had <code>192.168.1.129\/24<\/code>. The gateways were <code>192.168.18.1<\/code> and <code>192.168.1.254<\/code>, respectively. These are private LAN addresses from my network at the time; your addresses and gateways will be different.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Confirming the two wireless adapters<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The first step was to make sure both interfaces were up and scan for networks using each adapter individually.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip link set wlan0 up\nsudo ip link set wlan1 up\nsudo nmcli radio wifi on\n\n# Scan for networks visible to the built-in adapter.\nsudo nmcli device wifi list ifname wlan0\n\n# Scan for networks visible to the USB adapter.\nsudo nmcli device wifi list ifname wlan1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Using <code>ifname<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>connection.interface-name<\/code>: binds a saved profile to <code>wlan0<\/code> or <code>wlan1<\/code>.<\/li>\n\n\n\n<li><code>connection.autoconnect yes<\/code>: automatically reconnects the profile.<\/li>\n\n\n\n<li><code>connection.autoconnect-priority 100<\/code>: gives the intended profile a high autoconnect priority.<\/li>\n\n\n\n<li><code>802-11-wireless.ssid<\/code>: identifies the network for that profile.<\/li>\n\n\n\n<li>WPA-PSK security settings: used for the secured Wi-Fi networks.<\/li>\n\n\n\n<li><code>802-11-wireless-security.psk-flags 0<\/code>: tells NetworkManager to store the PSK in its system connection profile rather than asking for it every time.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For a new machine, I recommend entering passwords interactively rather than putting them directly into a script. For example:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo nmcli --ask device wifi connect \"PRIMARY_WIFI_SSID\" ifname wlan0\nsudo nmcli --ask device wifi connect \"SECONDARY_WIFI_SSID\" ifname wlan1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">After creating the profiles, I checked them with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nmcli connection show\nnmcli device status<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The important point is that <code>wlan0<\/code> and <code>wlan1<\/code> each have a persistent, separate NetworkManager profile. The configuration does not depend on manually reconnecting to the right network every time the Pi reboots.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. Why I used two Docker Compose projects<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of putting every service into one large Compose project, I created two project directories under my home directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/home\/&lt;user&gt;\/\n\u251c\u2500\u2500 money4band\/\n\u2502   \u251c\u2500\u2500 .env\n\u2502   \u251c\u2500\u2500 docker-compose.yaml\n\u2502   \u2514\u2500\u2500 application data and configuration\n\u2502\n\u2514\u2500\u2500 money4band2\/\n    \u251c\u2500\u2500 .env\n    \u251c\u2500\u2500 docker-compose.yaml\n    \u2514\u2500\u2500 application data and configuration<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>.env<\/code> file and its own Compose project name.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The environment files use different values for the settings that must not collide:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Setting<\/th><th>First project<\/th><th>Second project<\/th><\/tr><tr><td>Compose project name<\/td><td><code>money4band<\/code><\/td><td><code>money4band2<\/code><\/td><\/tr><tr><td>Docker network driver<\/td><td><code>bridge<\/code><\/td><td><code>bridge<\/code><\/td><\/tr><tr><td>Docker subnet<\/td><td><code>172.19.7.0\/27<\/code><\/td><td><code>172.20.7.0\/27<\/code><\/td><\/tr><tr><td>Device name<\/td><td>Unique first-stack name<\/td><td>Unique second-stack name<\/td><\/tr><tr><td>Watchtower scope<\/td><td><code>money4band<\/code><\/td><td><code>money4band2<\/code><\/td><\/tr><tr><td>Host dashboard port<\/td><td><code>8081<\/code> in the saved snapshot<\/td><td>Must be different if both dashboards publish a port<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The saved environment snapshots showed <code>M4B_DASHBOARD_PORT=8081<\/code> 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 <code>8082<\/code>, and the corresponding dashboard URL must use that port. This is an important setting to check when duplicating the stack.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each <code>.env<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Configure each Docker bridge network with its own subnet<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The final configuration uses Docker&#8217;s <code>bridge<\/code> network driver. Each Compose project creates its own default bridge network, with a separate IP address range.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first project&#8217;s environment includes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>COMPOSE_PROJECT_NAME=money4band\nNETWORK_DRIVER=bridge\nNETWORK_SUBNET=172.19.7.0\nNETWORK_NETMASK=27\nDETECTED_ARCHITECTURE=aarch64\nDETECTED_DOCKER_ARCH=arm64\nM4B_WATCHTOWER_SCOPE=money4band<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second project&#8217;s corresponding values include:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>COMPOSE_PROJECT_NAME=money4band2\nNETWORK_DRIVER=bridge\nNETWORK_SUBNET=172.20.7.0\nNETWORK_NETMASK=27\nDETECTED_ARCHITECTURE=aarch64\nDETECTED_DOCKER_ARCH=arm64\nM4B_WATCHTOWER_SCOPE=money4band2<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The Compose file defines the default network using those environment variables:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>networks:\n  default:\n    driver: ${NETWORK_DRIVER}\n    ipam:\n      config:\n        - subnet: ${NETWORK_SUBNET}\/${NETWORK_NETMASK}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This results in two distinct Docker bridge networks:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>First project: <code>172.19.7.0\/27<\/code><\/li>\n\n\n\n<li>Second project: <code>172.20.7.0\/27<\/code><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The host gets a bridge-side address for each network. In the recorded configuration, the gateways were <code>172.19.7.1<\/code> and <code>172.20.7.1<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Why a Docker bridge network does not automatically select a Wi-Fi adapter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>wlan0<\/code> or <code>wlan1<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Initially, I investigated using Docker <code>macvlan<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This distinction is important:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Docker bridge networking creates the separate container networks.<\/li>\n\n\n\n<li>Linux policy routing decides which physical Wi-Fi interface forwards packets from each Docker subnet.<\/li>\n\n\n\n<li>NetworkManager maintains the two wireless connections and their IP addresses.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s bridge network.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">6. Creating separate Linux routing tables<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Raspberry Pi has two active Wi-Fi connections, which means the host&#8217;s main routing table can contain two default routes. In my diagnostic output, the primary route through <code>wlan0<\/code> had metric <code>600<\/code>, and the secondary route through <code>wlan1<\/code> had metric <code>601<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A normal host-originated connection will generally follow the main routing table&#8217;s route selection. That is not enough for this project because I wanted container traffic from one subnet to use <code>wlan0<\/code> and container traffic from the other subnet to use <code>wlan1<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Linux policy routing solves this by allowing rules to select a different routing table according to the packet&#8217;s source address.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I used these assignments:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Docker source subnet<\/th><th>Physical interface<\/th><th class=\"has-text-align-right\" data-align=\"right\">Routing table<\/th><th class=\"has-text-align-right\" data-align=\"right\">Rule priority<\/th><\/tr><tr><td><code>172.19.7.0\/27<\/code><\/td><td><code>wlan0<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\"><code>100<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\"><code>1000<\/code><\/td><\/tr><tr><td><code>172.20.7.0\/27<\/code><\/td><td><code>wlan1<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\"><code>101<\/code><\/td><td class=\"has-text-align-right\" data-align=\"right\"><code>1001<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The first table contains a connected route for the Wi-Fi network on <code>wlan0<\/code>, plus a default route through its gateway. The second table does the same for <code>wlan1<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The essential commands for the first route table look like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip -4 route replace 192.168.18.0\/24 \\\n  dev wlan0 scope link src 192.168.18.51 table 100\n\nsudo ip -4 route replace default \\\n  via 192.168.18.1 dev wlan0 src 192.168.18.51 metric 10 table 100<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The equivalent entries for the second interface use its own connected network, IP address, gateway, and table number:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip -4 route replace 192.168.1.0\/24 \\\n  dev wlan1 scope link src 192.168.1.129 table 101\n\nsudo ip -4 route replace default \\\n  via 192.168.1.254 dev wlan1 src 192.168.1.129 metric 10 table 101<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I also added an unreachable default route with metric <code>42760<\/code> to each custom table:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip -4 route replace unreachable default metric 42760 table 100\nsudo ip -4 route replace unreachable default metric 42760 table 101<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. Source-based policy rules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Once the routing tables are populated, policy rules connect each Docker subnet to its matching table:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip -4 rule add priority 1000 \\\n  from 172.19.7.0\/27 lookup 100\n\nsudo ip -4 rule add priority 1001 \\\n  from 172.20.7.0\/27 lookup 101<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first rule means that packets whose source address belongs to <code>172.19.7.0\/27<\/code> should use routing table <code>100<\/code>. The second rule sends packets sourced from <code>172.20.7.0\/27<\/code> to table <code>101<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>0<\/code>, the main table at <code>32766<\/code>, and the default table at <code>32767<\/code>. My custom rules use priorities <code>1000<\/code> and <code>1001<\/code>, so they are evaluated before the normal main-table rule.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. The Bash functions that manage the setup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>~\/.money4band-functions<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then I added a small loader to <code>~\/.bashrc<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Load Money4Band management commands in Bash.\n&#91; -f \"$HOME\/.money4band-functions\" ] &amp;&amp; source \"$HOME\/.money4band-functions\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The installer script created the functions file, added the loader to <code>.bashrc<\/code> if it was not already present, and sourced <code>.bashrc<\/code> so the functions became available in the current terminal. The installer also set the functions file to permission mode <code>600<\/code> 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&#8217;s saved connection profiles or interactive prompts instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>_m4b_wifi_uuid_for_ssid<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>_m4b_apply_route<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This helper receives four parameters:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>interface, Docker subnet, routing-table ID, rule priority<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For example:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>_m4b_apply_route wlan0 172.19.7.0\/27 100 1000\n_m4b_apply_route wlan1 172.20.7.0\/27 101 1001<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>dockernetwork<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the network restoration command. Its job is to:<\/p>\n\n\n\n<ol start=\"1\" class=\"wp-block-list\">\n<li>Request sudo authentication with <code>sudo -v<\/code>.<\/li>\n\n\n\n<li>Turn Wi-Fi on through NetworkManager.<\/li>\n\n\n\n<li>Find or create the saved profile for the first Wi-Fi adapter.<\/li>\n\n\n\n<li>Find or create the saved profile for the second Wi-Fi adapter.<\/li>\n\n\n\n<li>Bind each profile to its intended interface and enable autoconnect.<\/li>\n\n\n\n<li>Disable autoconnect for other saved Wi-Fi profiles without deleting them.<\/li>\n\n\n\n<li>Bring the correct saved profiles up on <code>wlan0<\/code> and <code>wlan1<\/code>.<\/li>\n\n\n\n<li>Apply the two Docker-subnet routing tables and policy rules.<\/li>\n\n\n\n<li>Print the interface status, route tables, and policy rules for inspection.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\"><code>dockerup<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>dockerup<\/code> function manages both projects. It first looks for a supported Compose filename in each project directory (<code>docker-compose.yaml<\/code>, <code>compose.yaml<\/code>, <code>docker-compose.yml<\/code>, or <code>compose.yml<\/code>). It validates both configurations with <code>docker compose config --quiet<\/code>, runs <code>dockernetwork<\/code>, and then starts both projects with <code>docker compose up -d<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The important part is that the network restoration runs before the containers are started:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker compose -f \"$compose1\" config --quiet\nsudo docker compose -f \"$compose2\" config --quiet\n\ndockernetwork || return 1\n\nsudo docker compose -f \"$compose1\" up -d\nsudo docker compose -f \"$compose2\" up -d<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">At the end, the function prints a table of running containers, status, and attached Docker networks.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\"><code>dockerdown<\/code><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>dockerdown<\/code> function finds both Compose files and runs <code>docker compose down<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The four commands I wanted available at the shell were therefore:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>dockernetwork\ndockerup\ndockerdown<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The underscore-prefixed helpers are implementation details used by those commands.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">9. What the Money4Band Compose configuration manages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Several services use restart policies such as <code>always<\/code> or <code>unless-stopped<\/code>, so Docker can restart them after a container or host restart. The Compose files also use JSON-file log rotation settings such as:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>logging:\n  driver: json-file\n  options:\n    max-size: 10m\n    max-file: \"3\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This keeps a container from growing its JSON log file indefinitely. The actual resource settings are read from <code>.env<\/code> variables and applied using Compose properties such as <code>cpus<\/code>, <code>mem_reservation<\/code>, and <code>mem_limit<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Watchtower is configured with labels and a per-project scope. The saved settings included a polling interval of <code>14400<\/code> seconds (four hours), rolling restarts, cleanup of replaced images, and access to the Docker socket:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>volumes:\n  - \/var\/run\/docker.sock:\/var\/run\/docker.sock<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Pay attention to RAM limits on a 4 GB Pi<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The saved Raspberry Pi environment file used <code>RAM_CAP_MB_DEFAULT=1536<\/code> and memory limits ranging from <code>614m<\/code> to <code>1536m<\/code> for its resource classes. A later saved environment file for the second project contained a <code>6144<\/code> MB RAM cap and higher per-class limits. That later value is larger than the Pi&#8217;s 4 GB of physical memory and should not be copied as a safe recommendation for this hardware.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>.env<\/code> files together and monitor actual use with:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo docker stats\nfree -h<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">10. ARM64 images and AMD64 compatibility<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The Raspberry Pi 5 runs a 64-bit ARM operating system, and Docker identifies the architecture as <code>arm64<\/code>. Not every image in a multi-service stack necessarily publishes an ARM64 build.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Compose files I worked with included a mixture of native ARM-oriented images and services explicitly configured with <code>platform: linux\/amd64<\/code>. For example, the saved second-stack Compose revision specified AMD64 for some services and ARM64 for others. I also installed <code>binfmt<\/code> emulation support and checked that the host reported additional supported platforms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>dockerup<\/code> function.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">11. Verifying that the two stacks use different routes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After running <code>dockernetwork<\/code> or <code>dockerup<\/code>, I check the network state instead of assuming the script worked.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, confirm the wireless profiles:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>nmcli -f DEVICE,TYPE,STATE,CONNECTION device status<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then inspect the custom policy rules and route tables:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>echo \"========== POLICY RULES ==========\"\nip -4 rule show\n\necho \"========== ROUTING TABLE 100 ==========\"\nip -4 route show table 100\n\necho \"========== ROUTING TABLE 101 ==========\"\nip -4 route show table 101<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The expected policy is:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Source addresses from <code>172.19.7.0\/27<\/code> use table <code>100<\/code>, whose default route exits <code>wlan0<\/code>.<\/li>\n\n\n\n<li>Source addresses from <code>172.20.7.0\/27<\/code> use table <code>101<\/code>, whose default route exits <code>wlan1<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The route lookup can be tested directly with example container source addresses:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>ip -4 route get 1.1.1.1 from 172.19.7.2\nip -4 route get 1.1.1.1 from 172.20.7.2<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first lookup should resolve through the route table for <code>wlan0<\/code>; the second should resolve through the route table for <code>wlan1<\/code>. 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It is also possible to watch packets on each interface:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo tcpdump -ni wlan0\nsudo tcpdump -ni wlan1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run the captures in separate terminals while generating traffic from the corresponding stack. This helps confirm which interface is carrying the traffic.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">12. Troubleshooting: <code>FIB table does not exist<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One issue I encountered was this error when running <code>dockernetwork<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Configuring wlan0 \/ Docker subnet 172.19.7.0\/27...\nError: ipv4: FIB table does not exist.\nFlush terminated<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first version of <code>_m4b_apply_route<\/code> tried to flush the numbered routing table before creating any routes in it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ip -4 route flush table \"$table\" || return 1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>|| return 1<\/code>, it stopped there and never reached the second interface.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The diagnostic output also showed only the standard rules and no custom policy rules yet. I checked <code>\/etc\/iproute2\/rt_tables<\/code>, but that file was not present on this Raspberry Pi. The important point is that the helper was using numeric table IDs (<code>100<\/code> and <code>101<\/code>); the missing text file was not itself the cause of the failed flush.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The corrective approach discussed was to check whether the table already contains routes and flush it only when there is something to remove:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>existing_routes=$(sudo ip -4 route show table \"$table\" 2&gt;\/dev\/null || true)\n\nif &#91;&#91; -n \"$existing_routes\" ]]; then\n  sudo ip -4 route flush table \"$table\" || return 1\nfi<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>~\/.money4band-functions<\/code>; replacing a function only in the current shell does not make that change survive a new terminal session or reboot.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">13. The final design at a glance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The complete routing layout is easier to understand as a mapping between the two Compose projects and the two physical Wi-Fi interfaces:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Layer<\/th><th>First Money4Band project<\/th><th>Second Money4Band project<\/th><\/tr><tr><td>Project directory<\/td><td><code>~\/money4band<\/code><\/td><td><code>~\/money4band2<\/code><\/td><\/tr><tr><td>Compose project name<\/td><td><code>money4band<\/code><\/td><td><code>money4band2<\/code><\/td><\/tr><tr><td>Docker network<\/td><td><code>money4band_default<\/code><\/td><td><code>money4band2_default<\/code><\/td><\/tr><tr><td>Docker subnet<\/td><td><code>172.19.7.0\/27<\/code><\/td><td><code>172.20.7.0\/27<\/code><\/td><\/tr><tr><td>Policy rule<\/td><td>Priority <code>1000<\/code><\/td><td>Priority <code>1001<\/code><\/td><\/tr><tr><td>Routing table<\/td><td><code>100<\/code><\/td><td><code>101<\/code><\/td><\/tr><tr><td>Wi-Fi device<\/td><td><code>wlan0<\/code><\/td><td><code>wlan1<\/code><\/td><\/tr><tr><td>Egress path<\/td><td>Primary Wi-Fi<\/td><td>Secondary Wi-Fi<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The overall process is:<\/p>\n\n\n\n<ol start=\"1\" class=\"wp-block-list\">\n<li>NetworkManager connects each wireless adapter to its assigned saved profile.<\/li>\n\n\n\n<li>Docker Compose creates a separate bridge network for each project.<\/li>\n\n\n\n<li>Containers receive addresses from the subnet assigned to their project.<\/li>\n\n\n\n<li>Linux policy rules match each packet&#8217;s source subnet.<\/li>\n\n\n\n<li>The matching routing table sends traffic through the correct Wi-Fi gateway.<\/li>\n\n\n\n<li>Bash functions reapply the network configuration and manage both Compose projects.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This project became more involved than starting a single Docker Compose file. I had to account for the Raspberry Pi&#8217;s ARM64 architecture, two wireless adapters, NetworkManager connection profiles, separate Docker subnets, Linux routing tables, policy-rule priorities, container resource limits, and image compatibility.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The result is a setup where <code>~\/money4band<\/code> is associated with the first Docker subnet and <code>wlan0<\/code>, while <code>~\/money4band2<\/code> uses a different subnet and <code>wlan1<\/code>. The <code>dockernetwork<\/code>, <code>dockerup<\/code>, and <code>dockerdown<\/code> Bash functions make the configuration easier to restore and operate without manually repeating every command.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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 &#8230; <a title=\"How I made money making apps work with 2 networks on a Raspberry Pi\" class=\"read-more\" href=\"https:\/\/bassettgraphics.com\/blog\/2026\/11\/08\/how-i-made-money-making-apps-work-with-2-networks-on-a-raspberry-pi\/\" aria-label=\"Read more about How I made money making apps work with 2 networks on a Raspberry Pi\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[24,22,21,23],"class_list":["post-18","post","type-post","status-publish","format-standard","hentry","category-uncategorized","tag-linux","tag-money-making-apps","tag-passive-income","tag-raspberry-pi"],"_links":{"self":[{"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/posts\/18","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/comments?post=18"}],"version-history":[{"count":1,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/posts\/18\/revisions"}],"predecessor-version":[{"id":19,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/posts\/18\/revisions\/19"}],"wp:attachment":[{"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/media?parent=18"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/categories?post=18"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/bassettgraphics.com\/blog\/wp-json\/wp\/v2\/tags?post=18"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}