Theoretical Background: Docker Deployment and System Testing
Module 6 Theory: Docker Deployment and System Testing
Learning Objectives
- Understand the multi-service Docker Compose architecture of ArbiterROS
- Analyse the ROS 2 Dockerfile construction and image selection constraints
- Understand the Raspberry Pi 4B deployment pipeline via
deploy-pi.sh - Study the sim-to-real transition and its architectural requirements
- Apply the system testing strategy across simulation, virtual ROS 2, and hardware
- Understand the TLS proxy pattern used in cloud deployment
6.4.1 Docker for Robotics
Containers package the entire software stack — ROS 2 Humble, rosbridge, Python nodes, Nuxt 3 — into reproducible units that run identically in simulation and on physical hardware, with only port mappings and device paths changing between environments.
Dockerfile for the ROS 2 Backend:
# backend/Dockerfile
FROM ros:humble # IMPORTANT: NOT ros:humble-ros-base
# ros2cli is broken in the base image
RUN apt-get update && apt-get install -y \
ros-humble-rosbridge-server \
ros-humble-nav2-bringup \
ros-humble-slam-toolbox \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY . /opt/ros2-robot/
WORKDIR /opt/ros2-robot
ENTRYPOINT ["/bin/bash", "-c", \
"source /opt/ros/humble/setup.bash && ./start_nodes.sh"]
Dockerfile for the Nuxt 3 Frontend:
# frontend/Dockerfile
FROM node:20-alpine
RUN npm install -g pnpm # pnpm only — never npm or yarn
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build # outputs to .output/
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
6.4.2 Docker Compose for Multi-Service Deployment
Local Full-Stack (docker-compose.yml):
services:
backend:
build: ./backend
ports:
- "9090:9090" # rosbridge WebSocket
- "8085:8085" # latency API
environment:
- DEPLOY_MODE=docker
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- ROSBRIDGE_URL=ws://backend:9090
depends_on:
- backend
Services communicate over the internal Docker bridge network using service names
as hostnames. The frontend Nuxt server uses ROSBRIDGE_URL=ws://backend:9090
for its server-side rosbridge proxy (see Module 6.2), while the browser connects
to ws://localhost:9090 directly for low-latency control.
Production Cloud (docker-compose.prod.yml):
services:
backend:
image: arbiterros-backend:latest
expose:
- "9090"
- "8085"
environment:
- VIRTUAL_HOST=arbiter-backend.txio.live
frontend:
image: arbiterros-frontend:latest
expose:
- "3000"
environment:
- VIRTUAL_HOST=arbiter.txio.live
- LETSENCRYPT_HOST=arbiter.txio.live
- ROSBRIDGE_URL=ws://backend:9090
nginx-proxy:
image: jwilder/nginx-proxy
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs
letsencrypt:
image: jrcs/letsencrypt-nginx-proxy-companion
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
The jwilder/nginx-proxy pattern auto-generates nginx virtual host
configurations from VIRTUAL_HOST environment variables and Let’s Encrypt certificates via the companion container. New services are automatically
proxied when their container starts.
6.4.3 Raspberry Pi 4B Deployment
The deploy-pi.sh script handles the complete Pi deployment pipeline in a
single command:
# deploy-pi.sh (key steps)
bash deploy-pi.sh install # first-time: install ROS2 + deps
bash deploy-pi.sh # deploy + start (pigpio driver default)
bash deploy-pi.sh --driver l298n # use L298N driver instead
bash deploy-pi.sh status # check Pi health
bash deploy-pi.sh logs # tail service logs
Pi-Specific Compose (docker-compose.pi.yml):
services:
backend:
build: ./backend
privileged: true # required for GPIO access
devices:
- /dev/ttyUSB0:/dev/ttyUSB0 # RPLidar serial port
- /dev/gpiomem:/dev/gpiomem # GPIO memory-mapped I/O
environment:
- DEPLOY_MODE=pi-docker
- DRIVER_TYPE=pigpio
- PIGPIO_HOST=localhost
The privileged: true flag grants the container access to the Pi’s GPIO
hardware. In production environments, a more targeted devices list is
preferred; privileged is used here for portability across Pi board
revisions.
6.4.4 Sim-to-Real Transition
ArbiterROS is specifically designed so that algorithms developed in simulation require no code changes when deployed to hardware. The transition relies on three architectural properties:
- Identical topic names and types: The virtual robot publishes
/scanassensor_msgs/LaserScanwith a full 360° field of view at 1° resolution. The RPLidar A1 publishes the same type with the same field of view. No algorithm code needs to distinguish between them. - Connection mode abstraction:
useRobotConnectionhides the sim/hardware distinction behind theConnectionModetype. The control loop, obstacle avoidance, and analytics composables callpublishVelocity(v, omega)regardless of the active mode. - Driver abstraction:
HardwareRobotloads a driver by parameter. Settingdriver_type: mockinpi4b_params.yamlruns the full ROS 2 stack without GPIO hardware, enabling algorithm testing on any Linux machine.
Physical Parameters Requiring Adjustment:
| Parameter | Virtual robot | Pi 4B hardware |
|---|---|---|
| Wheel base | 0.30 m | 0.20 m (per pi4b_params.yaml) |
| Wheel radius | 0.05 m | 0.033 m (TT motor standard) |
| Max linear velocity | 2.0 m/s | 0.4 m/s (conservative indoor) |
| Encoder CPR | N/A (simulated) | 360 |
These values are set in pi4b_params.yaml and loaded by
hardware_robot.py at startup — no source code changes required.
6.4.5 System Testing and Validation
Frontend Unit Tests (vitest):
# Run all 169 tests (~3 seconds)
cd frontend && pnpm test:run
# Test file coverage:
tests/composables/useMobileRobotSimulation.test.ts
tests/composables/useControlAlgorithms.test.ts
tests/composables/useSLAMNavigation.test.ts
tests/composables/useObstacleAvoidance.test.ts
tests/composables/useROSBridge.test.ts
tests/composables/useRobotConnection.test.ts
tests/composables/useMobileRobotWithROS2.test.ts
tests/composables/useAnalyticsEngine.test.ts
tests/lib/MobileRobotPhysics.test.ts
tests/lib/LaserScan.test.ts
tests/lib/FigureEightTrajectory.test.ts
tests/lib/SLAMMap.test.ts
Backend Health Check:
./start.sh status # prints: rosbridge up, latency API up, robot node running
curl http://localhost:8085/api/health
# {"status": "ok", "node": "latency_tracker", "uptime_s": 42.1}
Integration Test Sequence:
- Start backend:
docker compose up --build - Start frontend:
pnpm dev - Navigate to
/ros2-test— verify all topics show “connected” status - Navigate to
/control— switch to ROS 2 Virtual mode, run Proportional controller to a waypoint - Navigate to
/latency— verify mean latency ms for Docker mode - Navigate to
/slam-navigation— verify occupancy grid renders and updates as robot explores
6.4.6 Troubleshooting Common Issues
- rosbridge not connecting: Ensure the backend container is
fully started before the frontend attempts connection. Add
depends_on: backendwith a healthcheck if needed. - GPIO permission denied on Pi: Ensure
pigpiodis running (sudo pigpiod) and the container hasprivileged: true. - SLAM map not updating: The SLAM toolbox is delayed 5 s after
the robot node starts (see
robot_bringup.launch.py). Wait for the “slam_toolbox: online” log message. - Build fails with
npx nuxi build: Always usepnpm build— thenpx nuxi buildpath bypasses the required symlink step forpnpmworkspace resolution.
Integration: Theory to Practice
The Docker deployment is the integration layer that unifies all Chapter 6 modules.
docker-compose.yml runs the backend (which includes the autonomous
behaviour node from 6.1 and the latency tracker from 6.3) alongside the Nuxt
server (which implements the web control interface from 6.2) in a single
command.
On the Pi 4B, docker-compose.pi.yml additionally maps the RPLidar and
GPIO devices into the container for the hardware integration (6.5).
Theoretical Design Choices
Why ros:humble and not ros:humble-ros-base?ros:humble-ros-base omits ros2cli tools (ros2 topic, ros2 node, etc.). These tools are essential for debugging
inside a running container (docker exec -it backend ros2 topic list).
Using the full ros:humble image adds approximately 200 MB but makes
the container
self-diagnosable, significantly reducing debugging time.
Why pnpm and not npm or yarn?pnpm uses a content-addressable store and hard-links identical packages
across projects, reducing install time and disk usage by approximately 40% compared
to npm for a project of this dependency depth. The --frozen-lockfile flag in the Dockerfile ensures reproducible
builds — npm’s equivalent (ci) does not respect the workspace
protocol used by Nuxt 3.
Why jwilder/nginx-proxy over a hand-written nginx config?
Hand-writing nginx virtual host configurations for TLS requires manual certificate
renewal and configuration updates for each new service. The
jwilder/nginx-proxy + letsencrypt-companion pattern auto-generates
and renews Let’s Encrypt certificates, listens for container events via the Docker
socket, and updates the nginx configuration dynamically. Adding a new publicly
accessible service requires only adding two environment variables to its container.
Why privileged: true for the Pi backend container?
GPIO access on Linux requires either root privileges or membership in the
gpio group, neither of which is easily achieved inside a Docker container
without privileged. The more secure alternative — a custom capability set + /dev/gpiomem device
mapping — is board-revision-specific and breaks between Pi 3, Pi 4B,
and Pi 5. For an educational deployment, privileged provides
predictable cross-platform behaviour at the cost of container isolation.
Why ARM64/aarch64 images for Pi 4B? The Pi 4B uses a Cortex-A72 CPU
(ARMv8 / 64-bit). Standard ros:humble images are built for amd64.
The docker-compose.pi.yml specifies platform: linux/arm64 to
pull the correct image. Building amd64 images for Pi via QEMU emulation
is 10–20 slower and the resulting binaries run at approximately half the
native speed.