Running Multiple Reachy2 Simulations in Docker: Port Mapping and ROS 2 Domain Isolation
Three Reachy2 Sims on One Host — Port Mapping and ROS Domain Isolation
Running multiple Reachy2 simulations in parallel is entirely doable, but it requires getting two separate layers right: Docker port mapping for external addressing, and ROS 2 domain ID isolation to keep the containers from bleeding state into each other internally. Miss the second one and you’ll see bizarre behavior even when the port wiring looks correct.
How Reachy2’s Network Stack Is Laid Out
Each Reachy2 simulation container runs a gRPC SDK server on port 50051. That’s the endpoint the Python SDK — and the Unity digital-twin package — uses to send commands and read robot state. The container also exposes port 6080 for a noVNC web interface and port 8888 for Jupyter. These internal ports are fixed; Docker’s job is to present them on different host-side ports when multiple containers are running.
The key insight: every container always listens on port 50051 internally. You don’t change anything inside the image. You simply remap that port to a different number on the host for each instance.
Starting Three Containers with docker run
The -p HOST:CONTAINER flag does the remapping. Three containers, three commands:
docker run -d \
--name reachy_sim_1 \
-e ROS_DOMAIN_ID=1 \
-p 50051:50051 \
-p 6080:6080 \
-p 8888:8888 \
pollenrobotics/reachy2
docker run -d \
--name reachy_sim_2 \
-e ROS_DOMAIN_ID=2 \
-p 50052:50051 \
-p 6081:6080 \
-p 8889:8888 \
pollenrobotics/reachy2
docker run -d \
--name reachy_sim_3 \
-e ROS_DOMAIN_ID=3 \
-p 50053:50051 \
-p 6082:6080 \
-p 8890:8888 \
pollenrobotics/reachy2
The host port increments (50051, 50052, 50053); the container port stays at 50051 for all three. Same logic applies to the noVNC and Jupyter ports if you need per-container browser access to those. If your setup requires specific ROS 2 launch arguments — enabling Gazebo or MuJoCo, toggling the SDK server — pass them via the container’s command.
ROS_DOMAIN_ID: The Part People Miss
Port remapping handles network addressing. It does not handle ROS 2 discovery.
Reachy2’s software stack is built on ROS 2, which uses DDS (Data Distribution Service) for internal pub/sub messaging. By default, all ROS 2 nodes on the same network segment use domain ID 0 — and that includes nodes in different containers on the same host. Three containers running with the default domain ID will discover each other over DDS and share state. Commands sent to robot 1 show up in robot 2’s joint state topics. Simulation worlds collide in unexpected ways.
Setting a unique ROS_DOMAIN_ID per container walls off that DDS traffic completely. Valid values are integers 0 through 101. The -e ROS_DOMAIN_ID=N flag passes it as an environment variable at container start. Once set, each container’s ROS 2 graph is invisible to the others.
A Working Docker Compose File
Managing three separate docker run commands is friction. A Compose file makes the whole setup reproducible and gives you one-command startup and teardown.
version: '3.8'
services:
reachy_sim_1:
image: pollenrobotics/reachy2
container_name: reachy_sim_1
environment:
- ROS_DOMAIN_ID=1
ports:
- "50051:50051"
- "6080:6080"
- "8888:8888"
reachy_sim_2:
image: pollenrobotics/reachy2
container_name: reachy_sim_2
environment:
- ROS_DOMAIN_ID=2
ports:
- "50052:50051"
- "6081:6080"
- "8889:8888"
reachy_sim_3:
image: pollenrobotics/reachy2
container_name: reachy_sim_3
environment:
- ROS_DOMAIN_ID=3
ports:
- "50053:50051"
- "6082:6080"
- "8890:8888"
Start everything: docker compose up -d. Stop and clean up: docker compose down. Add a command: key under each service when you need to pass specific simulation launch arguments.
Connecting the Python SDK to All Three
Once the containers are up, each robot gets its own SDK connection pointed at a different host port. Check your installed reachy2-sdk version’s docs for the exact constructor parameters, but the structure looks like this:
from reachy2_sdk import ReachySDK
robot1 = ReachySDK(host='localhost', sdk_port=50051)
robot2 = ReachySDK(host='localhost', sdk_port=50052)
robot3 = ReachySDK(host='localhost', sdk_port=50053)
Replace 'localhost' with the host machine’s IP address if you’re connecting from a separate device. All three connections run in parallel without conflict — each maps to its own isolated container.
Display and Resource Considerations
Reachy2’s simulation image uses Xvfb (a virtual framebuffer), so no physical display or X11 forwarding is required. The noVNC server on port 6080 gives you a browser window into each container’s virtual Unity environment when you need visual inspection. Navigate to http://localhost:6080, http://localhost:6081, or http://localhost:6082 for each respective container.
Three concurrent Unity instances will put real load on the host. Without GPU passthrough configured, all three software-render, which can peg CPU usage. Watch resource consumption with docker stats. If one container is starving the others, add cpus and mem_limit constraints in the Compose file to divide host resources evenly.
Verifying Isolation
After startup, the fastest check: connect all three SDK instances and send each robot a distinct joint command. Only that robot should move. If commands to one robot trigger state changes in another, domain ID collision is almost certainly the cause.
Confirm what each container actually received:
docker inspect reachy_sim_1 | grep ROS_DOMAIN_ID
docker inspect reachy_sim_2 | grep ROS_DOMAIN_ID
docker inspect reachy_sim_3 | grep ROS_DOMAIN_ID
Each line should show a different integer. If all three show 0 or return nothing, the environment variable didn’t get passed — recheck the -e flag syntax in your run commands, or the environment: block indentation in the Compose file.
Sources
- docs.pollen-robotics.com
- hub.docker.com
- github.com
- github.com
- discourse.openrobotics.org
- digitalocean.com
- nutrient.io
- huggingface.co
