agentleFS
jewel-nathjewel-nath/agentic-robotics/skills/docker-ros2-ci/SKILL.md

docker-ros2-ci

Skill3 starsChanged 8 months agoApache-2.0View on GitHub

Docker-based ROS2 development and CI: multi-stage Dockerfiles, docker-compose for robot stacks, DDS discovery across containers, GPU and USB passthrough, and CI builds/tests. Use when containerizing ROS2 workspaces, setting up CI with colcon build/test, or running multi-container robot systems.

  • Reads credentials
  • Deletes or force-pushes
  • Installs packages

The file

We split this skill into the rules it gives an agent

9 rules. About 0 are common, 2 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Use the smallest OSRF image that has what you need.

    line 44
  2. Very specific:

    When not using network_mode: host, list peers by service name or IP.

    line 141
  3. Distinctive:

    Use the same Dockerfile; run tests in the build or a test stage.

    line 160
  4. Very specific:

    Use ros-base or ros-core for runtime to reduce image size and attack surface.

    line 182
  5. Very specific:

    Create a non-root user in the runtime stage and set USER so the process does not run as root.

    line 190
  6. Very specific:

    Use robot-bringup for production startup on the robot (systemd, launch layers).

    line 219
  7. Very specific:

    When you copy src/ before step 2, any edit to any source file invalidates the rosdep layer and re-downloads all apt packages — a 5–20 …

    line 236
  8. Very specific:

    Tip: Use a find-based COPY helper script or .dockerignore to auto-copy only package.xml files if your project grows beyond a handful of …

    line 272
  9. Very specific:

    Run multiple robot instances with isolated ROS_DOMAIN_ID values on a single host.

    line 674

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Docker and CI for ROS2

This skill covers Docker-based ROS2 development and CI: image choice, multi-stage Dockerfiles, docker-compose for multi-container systems, DDS configuration in containers, and using the same images for local dev and CI (e.g. GitHub Actions).

When to Use

  • Writing Dockerfiles for ROS2 workspaces with colcon builds
  • Setting up docker-compose for multi-container robot systems (drivers, perception, navigation)
  • Debugging DDS discovery between containers (CycloneDDS, FastDDS)
  • Configuring GPU passthrough for perception nodes
  • Managing USB passthrough for cameras and serial devices
  • Building CI/CD pipelines that run colcon build and colcon test in Docker
  • Choosing the right OSRF base image and optimizing layer caching

Quick Start

# Build workspace in Docker (from repo root with src/ and Dockerfile)
docker build --target build -t my_robot:build .
docker run --rm -v $(pwd)/src:/ros2_ws/src my_robot:build \
  bash -c "source /opt/ros/humble/setup.bash && colcon build"

# Run a launch file in a minimal runtime image
docker build --target runtime -t my_robot:latest .
docker run --rm --network host -e ROS_DOMAIN_ID=0 my_robot:latest

Core Concepts

ROS2 Docker Image Hierarchy

Use the smallest OSRF image that has what you need.

ImageSizeContentsUse case
ros:humble-ros-core~700 MBrclcpp, rclpy, launchMinimal runtime
ros:humble-ros-base~1.1 GB+ common_interfaces, rosbag2Production
ros:humble-perception~2.2 GB+ image_transport, cv_bridge, PCLPerception
ros:humble-desktop~2.8 GB+ rviz2, rqtDevelopment with GUI

Multi-Stage Builds

  • deps: Copy only package.xml files, run rosdep install — maximizes cache when source changes.
  • build: Copy src/, run colcon build.
  • runtime: Copy only install/ from build; no compilers or source. Use for production and CI test runs.

DDS in Containers

  • With bridge networking, multicast often does not work. Use CycloneDDS or FastDDS with explicit peer lists (service names in compose, or IPs).
  • Share /dev/shm (or shm_size) for shared-memory transport when multiple containers need high-throughput topics.
  • network_mode: host gives native multicast but less isolation.

Common Patterns

Multi-Stage Dockerfile

ARG ROS_DISTRO=humble
FROM ros:${ROS_DISTRO}-ros-base AS deps
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-colcon-common-extensions python3-rosdep && rm -rf /var/lib/apt/lists/*
WORKDIR /ros2_ws
COPY src/my_pkg/package.xml src/my_pkg/package.xml
RUN . /opt/ros/${ROS_DISTRO}/setup.sh && \
    apt-get update && rosdep install --from-paths src --ignore-src -r -y && rm -rf /var/lib/apt/lists/*

FROM deps AS build
COPY src/ src/
RUN . /opt/ros/${ROS_DISTRO}/setup.sh && \
    colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release --event-handlers console_direct+

FROM ros:${ROS_DISTRO}-ros-core AS runtime
ARG ROS_DISTRO
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-yaml ros-${ROS_DISTRO}-rmw-cyclonedds-cpp && rm -rf /var/lib/apt/lists/*
COPY --from=build /ros2_ws/install /ros2_ws/install
RUN groupadd -r rosuser && useradd -r -g rosuser -m rosuser
USER rosuser
ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
COPY ros_entrypoint.sh /ros_entrypoint.sh
ENTRYPOINT ["/ros_entrypoint.sh"]
CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]

Entrypoint Script

#!/bin/bash
set -e
. /opt/ros/${ROS_DISTRO}/setup.bash
[ -f /ros2_ws/install/setup.bash ] && . /ros2_ws/install/setup.bash
exec "$@"

docker-compose with Shared DDS Config

x-ros-common: &ros-common
  environment:
    ROS_DOMAIN_ID: 0
    RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
    CYCLONEDDS_URI: file:///cyclonedds.xml
  volumes:
    - ./config/cyclonedds.xml:/cyclonedds.xml:ro
    - /dev/shm:/dev/shm
  network_mode: host

services:
  driver:
    <<: *ros-common
    image: my_robot_driver:latest
    command: ros2 launch my_robot_driver driver.launch.py
    devices:
      - /dev/ttyUSB0:/dev/ttyUSB0
    group_add: [dialout]

  perception:
    <<: *ros-common
    image: my_robot_perception:latest
    command: ros2 launch my_robot_perception perception.launch.py
    deploy:
      resources:
        reservations:
          devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]

CycloneDDS Peer List (for bridge networking)

When not using network_mode: host, list peers by service name or IP.

<CycloneDDS xmlns="https://cdds.io/config">
  <Domain>
    <General><AllowMulticast>false</AllowMulticast></General>
    <Discovery>
      <Peers>
        <Peer address="driver"/>
        <Peer address="perception"/>
        <Peer address="navigation"/>
      </Peers>
    </Discovery>
  </Domain>
</CycloneDDS>

CI: Build and Test in Docker

Use the same Dockerfile; run tests in the build or a test stage.

# .github/workflows/ci.yml (example)
- name: Build and test
  run: |
    docker build --target build -t my_robot:test .
    docker run --rm my_robot:test bash -c "\
      source /opt/ros/humble/setup.bash && \
      source /ros2_ws/install/setup.bash && \
      colcon test --event-handlers console_direct+ && \
      colcon test-result --all"

Anti-Patterns

Copying entire repo before rosdep

Copy only package.xml (or per-package package.xml) first, run rosdep install, then copy src/. Otherwise any source change invalidates the dependency layer and reinstalls packages.

Using desktop image for production

Use ros-base or ros-core for runtime to reduce image size and attack surface.

Forgetting /dev/shm for multi-container DDS

Large topics (images, point clouds) use shared memory when enabled. Give containers enough shm_size or mount /dev/shm and enable shared memory in CycloneDDS config.

Running as root in container

Create a non-root user in the runtime stage and set USER so the process does not run as root.

Relying on multicast in bridge network

With default bridge networking, DDS multicast often fails. Use explicit peer lists and set AllowMulticast to false in CycloneDDS.

Configuration Reference

Variable / optionDescription
ROS_DISTROhumble, iron, jazzy — must match base image
RMW_IMPLEMENTATIONrmw_cyclonedds_cpp, rmw_fastrtps_cpp
CYCLONEDDS_URIfile:///path/to/cyclonedds.xml
ROS_DOMAIN_IDSame across containers that should discover each other
network_mode: hostUse host network; DDS multicast works, less isolation
shm_sizeIncrease for image/point cloud topics (e.g. 512m)

Troubleshooting

SymptomCauseSolution
Containers do not see each other's topicsDDS discovery (multicast/peers)Use peer list in CycloneDDS/FastDDS; or network_mode: host
"No space left on device" during buildFull disk or small /dev/shmIncrease disk/shm; clean images and build cache
colcon test fails in CI onlyTiming, resources, or envRun with --event-handlers console_direct+; ensure enough memory/CPU in runner
GPU not available in containerMissing NVIDIA Container ToolkitInstall nvidia-container-toolkit; use deploy.reservations.devices (gpu) in compose
USB device not visiblePermissions or not passedUse devices: and group_add: dialout (or video) as needed

Workflow Integration

  • Use robot-bringup for production startup on the robot (systemd, launch layers); use this skill for containerized builds and optional containerized deployment.
  • For testing patterns (pytest, launch_testing) see rules/robotics-testing.md and skills/ros2/SKILL.md.
  • For web interfaces to ROS2 see ros2-web-bridge.

Extended Core Concepts

Layer Caching Strategy

Docker builds cache each layer. The goal is to put slow, rarely-changing steps early and fast, frequently-changing steps late. For ROS2 workspaces:

  1. System packages — apt-get install (changes only when you add a new dep)
  2. rosdep dependencies — copy only package.xml files, run rosdep install (changes only when a package.xml changes)
  3. Source build — copy src/, run colcon build (rebuilds on any source change)
  4. Runtime copy — COPY --from=build /ros2_ws/install /ros2_ws/install

When you copy src/ before step 2, any edit to any source file invalidates the rosdep layer and re-downloads all apt packages — a 5–20 minute penalty per CI run.

ARG ROS_DISTRO=jazzy
FROM ros:${ROS_DISTRO}-ros-base AS deps

# Install build tools first — rarely changes
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-colcon-common-extensions \
    python3-rosdep \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /ros2_ws

# Copy ONLY package.xml files — preserves cache when .cpp/.py files change
COPY src/orbibot_hardware/package.xml        src/orbibot_hardware/package.xml
COPY src/orbibot_sensors/package.xml         src/orbibot_sensors/package.xml
COPY src/orbibot_navigation/package.xml      src/orbibot_navigation/package.xml
COPY src/orbibot_msgs/package.xml            src/orbibot_msgs/package.xml

# rosdep layer — only rebuilds when package.xml files change
RUN rosdep update && \
    . /opt/ros/${ROS_DISTRO}/setup.sh && \
    rosdep install --from-paths src --ignore-src -r -y && \
    rm -rf /var/lib/apt/lists/*

# Now copy source — only this layer rebuilds on source changes
FROM deps AS build
COPY src/ src/
RUN . /opt/ros/${ROS_DISTRO}/setup.sh && \
    colcon build \
      --cmake-args -DCMAKE_BUILD_TYPE=Release \
      --event-handlers console_direct+ \
      --parallel-workers 4

Tip: Use a find-based COPY helper script or .dockerignore to auto-copy only package.xml files if your project grows beyond a handful of packages:

# scripts/copy_package_xmls.sh — run before docker build to stage package.xml files
find src -name "package.xml" | while read f; do
  mkdir -p "docker_context/$(dirname $f)"
  cp "$f" "docker_context/$f"
done

Non-Root User Setup

Running ROS2 nodes as root inside a container is unnecessary and increases risk. Create a dedicated user in the runtime stage only (not in the build stage, which often needs root for apt).

FROM ros:${ROS_DISTRO}-ros-core AS runtime
ARG ROS_DISTRO=jazzy
ARG UID=1000
ARG GID=1000

# Install minimal runtime deps as root
RUN apt-get update && apt-get install -y --no-install-recommends \
    ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \
    python3-yaml \
    && rm -rf /var/lib/apt/lists/*

# Create group and user matching the host UID/GID (avoids bind-mount permission issues)
RUN groupadd -g ${GID} rosuser && \
    useradd -u ${UID} -g ${GID} -m -s /bin/bash rosuser && \
    mkdir -p /ros2_ws && \
    chown -R rosuser:rosuser /ros2_ws

# Copy workspace install from build stage
COPY --from=build --chown=rosuser:rosuser /ros2_ws/install /ros2_ws/install

# Copy entrypoint as root, then switch user
COPY ros_entrypoint.sh /ros_entrypoint.sh
RUN chmod +x /ros_entrypoint.sh

USER rosuser
WORKDIR /ros2_ws

ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
ENTRYPOINT ["/ros_entrypoint.sh"]
CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]

Build with matching host UID to avoid bind-mount ownership issues:

docker build \
  --build-arg UID=$(id -u) \
  --build-arg GID=$(id -g) \
  --build-arg ROS_DISTRO=jazzy \
  -t my_robot:latest .

DDS Network Modes in Docker

Docker offers three relevant network modes for ROS2. Choose based on isolation vs. discovery needs.

ModeMulticastIsolationWhen to Use
hostWorks nativelyNone — shares host networkSingle robot, dev containers, simplest setup
bridge (default)BlockedContainers isolatedMulti-container on one host; requires peer list
Custom overlayConfigurableSwarm-wideMulti-host Docker Swarm; advanced

Host mode — simplest, no DDS config needed:

services:
  driver:
    network_mode: host
    environment:
      ROS_DOMAIN_ID: "42"

Bridge mode with CycloneDDS peer list — containers discover each other by service name:

<!-- config/cyclonedds_bridge.xml -->
<CycloneDDS xmlns="https://cdds.io/config">
  <Domain>
    <General>
      <AllowMulticast>false</AllowMulticast>
      <MaxMessageSize>65500B</MaxMessageSize>
    </General>
    <Discovery>
      <ParticipantIndex>auto</ParticipantIndex>
      <Peers>
        <Peer address="driver"/>
        <Peer address="perception"/>
        <Peer address="navigation"/>
        <Peer address="webgui"/>
      </Peers>
    </Discovery>
    <Internal>
      <Watermarks>
        <WhcHigh>500kB</WhcHigh>
      </Watermarks>
    </Internal>
  </Domain>
</CycloneDDS>

FastDDS with XML peer list (alternative to CycloneDDS):

<!-- config/fastdds_bridge.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<dds xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
  <profiles>
    <participant profile_name="bridge_profile" is_default_profile="true">
      <rtps>
        <builtin>
          <metatrafficUnicastLocatorList>
            <locator/>
          </metatrafficUnicastLocatorList>
          <initialPeersList>
            <locator>
              <udpv4><address>driver</address></udpv4>
            </locator>
            <locator>
              <udpv4><address>perception</address></udpv4>
            </locator>
          </initialPeersList>
        </builtin>
      </rtps>
    </participant>
  </profiles>
</dds>

Set in environment:

environment:
  RMW_IMPLEMENTATION: rmw_fastrtps_cpp
  FASTRTPS_DEFAULT_PROFILES_FILE: /config/fastdds_bridge.xml

Extended Common Patterns

Dev Container with Volume Mounts

A dev container builds once but recompiles inside the container, with your source tree mounted as a bind mount. This gives instant file-sync without rebuilding the image on every change.

# Dockerfile.dev — dev image only; not for production
ARG ROS_DISTRO=jazzy
FROM ros:${ROS_DISTRO}-ros-base AS dev

RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-colcon-common-extensions \
    python3-rosdep \
    python3-pip \
    vim \
    gdb \
    ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \
    && rm -rf /var/lib/apt/lists/*

# Pre-install deps so bind-mount workspace builds quickly
WORKDIR /ros2_ws
COPY src/*/package.xml /tmp/pkg_xmls/
# Use a helper to restore directory structure for rosdep
RUN mkdir -p src && find /tmp/pkg_xmls -name "package.xml" -exec bash -c \
    'pkg=$(basename $(dirname $1)); mkdir -p src/$pkg; cp $1 src/$pkg/' _ {} \; && \
    rosdep update && \
    . /opt/ros/${ROS_DISTRO}/setup.sh && \
    rosdep install --from-paths src --ignore-src -r -y && \
    rm -rf /var/lib/apt/lists/*

# Entrypoint sources ROS and workspace if installed
COPY ros_entrypoint.sh /ros_entrypoint.sh
RUN chmod +x /ros_entrypoint.sh
ENTRYPOINT ["/ros_entrypoint.sh"]
CMD ["bash"]
# docker-compose.dev.yaml
services:
  dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my_robot:dev
    network_mode: host
    environment:
      ROS_DOMAIN_ID: "0"
      RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
    volumes:
      # Bind-mount source — edits reflect instantly without rebuild
      - ./src:/ros2_ws/src:rw
      # Persist colcon build cache across container restarts
      - colcon_build:/ros2_ws/build
      - colcon_install:/ros2_ws/install
      - colcon_log:/ros2_ws/log
      # Host X11 for RViz (Linux)
      - /tmp/.X11-unix:/tmp/.X11-unix:rw
      # Device access
      - /dev/motordriver:/dev/motordriver
    devices:
      - /dev/motordriver:/dev/motordriver
    privileged: false
    environment:
      DISPLAY: "${DISPLAY}"
    group_add:
      - dialout

volumes:
  colcon_build:
  colcon_install:
  colcon_log:

Usage:

# Start dev container
docker compose -f docker-compose.dev.yaml up -d dev

# Build inside container
docker compose -f docker-compose.dev.yaml exec dev bash -c \
  "source /opt/ros/jazzy/setup.bash && colcon build --symlink-install"

# Run launch file inside container
docker compose -f docker-compose.dev.yaml exec dev bash -c \
  "source /opt/ros/jazzy/setup.bash && \
   source /ros2_ws/install/setup.bash && \
   ros2 launch orbibot_bringup robot.launch.py"

CI/CD Pipeline with colcon build and test

A complete GitHub Actions workflow using Docker layer caching via the GitHub Actions cache backend.

# .github/workflows/ros2_ci.yml
name: ROS2 CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-test:
    runs-on: ubuntu-24.04

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Build deps stage — cached by layer hash of package.xml files
      - name: Build deps stage
        uses: docker/build-push-action@v5
        with:
          context: .
          target: deps
          push: false
          load: true
          tags: ${{ env.IMAGE_NAME }}:deps
          cache-from: type=gha,scope=deps
          cache-to: type=gha,mode=max,scope=deps

      # Build full image
      - name: Build workspace
        uses: docker/build-push-action@v5
        with:
          context: .
          target: build
          push: false
          load: true
          tags: ${{ env.IMAGE_NAME }}:ci
          cache-from: |
            type=gha,scope=deps
            type=gha,scope=build
          cache-to: type=gha,mode=max,scope=build

      # Run colcon test inside the built image
      - name: Run tests
        run: |
          docker run --rm \
            -e ROS_DOMAIN_ID=99 \
            -e RMW_IMPLEMENTATION=rmw_cyclonedds_cpp \
            ${{ env.IMAGE_NAME }}:ci \
            bash -c "
              source /opt/ros/jazzy/setup.bash && \
              source /ros2_ws/install/setup.bash && \
              colcon test \
                --packages-select orbibot_hardware orbibot_msgs \
                --event-handlers console_direct+ && \
              colcon test-result --all --verbose
            "

      # Push runtime image on merge to main
      - name: Build and push runtime image
        if: github.ref == 'refs/heads/main'
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha,scope=build

Multi-Arch Builds (AMD64 + ARM64 for Raspberry Pi)

OrbiBot's RPi 5 runs ARM64. Build and push a multi-arch manifest so the same tag works on both the development laptop (AMD64) and the robot.

# .github/workflows/multiarch.yml
name: Multi-Arch Build

on:
  push:
    tags: ['v*']

jobs:
  build-multiarch:
    runs-on: ubuntu-24.04

    steps:
      - uses: actions/checkout@v4

      - name: Set up QEMU (emulate ARM64 on AMD64 runner)
        uses: docker/setup-qemu-action@v3
        with:
          platforms: arm64

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push multi-arch image
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          target: runtime
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
          # Separate cache scopes per platform to avoid cross-contamination
          cache-from: |
            type=gha,scope=amd64
            type=gha,scope=arm64
          cache-to: type=gha,mode=max,scope=${{ runner.arch == 'X64' && 'amd64' || 'arm64' }}

Local cross-compile test (slow — uses QEMU emulation):

# Build ARM64 image locally
docker buildx build \
  --platform linux/arm64 \
  --target runtime \
  -t my_robot:arm64-test \
  --load \
  .

# Verify architecture
docker run --rm my_robot:arm64-test uname -m  # Should print: aarch64

ARM64-specific Dockerfile considerations:

ARG ROS_DISTRO=jazzy
# OSRF publishes multi-arch images — FROM automatically pulls the right arch
FROM ros:${ROS_DISTRO}-ros-base AS deps

# Some Python packages have no ARM64 wheel; install from apt when possible
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-numpy \
    python3-scipy \
    && rm -rf /var/lib/apt/lists/*
# Avoid: pip install numpy (compiles from source on ARM64, very slow in QEMU)

docker-compose for Multi-Robot Simulation

Run multiple robot instances with isolated ROS_DOMAIN_ID values on a single host.

# docker-compose.simulation.yaml
x-robot-base: &robot-base
  image: my_robot:latest
  volumes:
    - ./config/cyclonedds.xml:/cyclonedds.xml:ro
    - /dev/shm:/dev/shm

services:
  robot_1:
    <<: *robot-base
    container_name: robot_1
    environment:
      ROS_DOMAIN_ID: "1"
      ROBOT_NAME: "robot_1"
      RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
      CYCLONEDDS_URI: file:///cyclonedds.xml
    networks:
      - sim_net
    command: ros2 launch my_pkg bringup.launch.py robot_name:=robot_1

  robot_2:
    <<: *robot-base
    container_name: robot_2
    environment:
      ROS_DOMAIN_ID: "2"
      ROBOT_NAME: "robot_2"
      RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
      CYCLONEDDS_URI: file:///cyclonedds.xml
    networks:
      - sim_net
    command: ros2 launch my_pkg bringup.launch.py robot_name:=robot_2

  # Fleet coordinator — subscribes to all robots via DDS domain bridge or a REST proxy
  coordinator:
    image: my_robot_coordinator:latest
    environment:
      ROBOT_1_URL: "http://robot_1:8080"
      ROBOT_2_URL: "http://robot_2:8080"
    networks:
      - sim_net
    depends_on:
      - robot_1
      - robot_2

networks:
  sim_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

GPU Passthrough for Gazebo / Perception

NVIDIA GPU passthrough requires the NVIDIA Container Toolkit on the host.

# Install NVIDIA Container Toolkit (host, one-time)
distribution=$(. /etc/os-release; echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/libnvidia-container/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list | \
  sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker
# docker-compose with GPU
services:
  gazebo:
    image: my_robot_sim:latest
    environment:
      DISPLAY: "${DISPLAY}"
      NVIDIA_VISIBLE_DEVICES: all
      NVIDIA_DRIVER_CAPABILITIES: graphics,compute,utility
      ROS_DOMAIN_ID: "0"
    volumes:
      - /tmp/.X11-unix:/tmp/.X11-unix:rw
      - /dev/shm:/dev/shm
    network_mode: host
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    command: ros2 launch my_pkg gazebo_sim.launch.py

For Intel integrated GPU (no NVIDIA toolkit needed):

services:
  rviz:
    image: my_robot:desktop
    devices:
      - /dev/dri:/dev/dri
    environment:
      DISPLAY: "${DISPLAY}"
    volumes:
      - /tmp/.X11-unix:/tmp/.X11-unix:rw

Extended Anti-Patterns

Running as root in the container

❌ Wrong — default root user in runtime image:

FROM ros:jazzy-ros-core
COPY --from=build /ros2_ws/install /ros2_ws/install
CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]
# Container runs as root — any exploit gets full root access

✅ Correct — create and switch to a non-root user:

FROM ros:jazzy-ros-core
RUN groupadd -r rosuser && useradd -r -g rosuser -m rosuser && \
    mkdir -p /ros2_ws && chown rosuser:rosuser /ros2_ws
COPY --from=build --chown=rosuser:rosuser /ros2_ws/install /ros2_ws/install
USER rosuser
CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]

Not pinning the ROS2 distro tag

❌ Wrong — unpinned base image breaks builds when OSRF releases a new patch:

FROM ros:latest         # Could be any distro
FROM ros:humble         # Rolling tag; minor updates may break your deps

✅ Correct — pin to a specific digest or at minimum a distro + release date tag:

ARG ROS_DISTRO=jazzy
FROM ros:${ROS_DISTRO}-ros-base
# For maximum reproducibility, pin to a digest:
# FROM ros:jazzy-ros-base@sha256:abc123...

Automate digest updates with Dependabot or Renovate rather than manually tracking them.

DDS discovery issues in Docker bridge network

❌ Wrong — relying on multicast with bridge networking and no peer config:

services:
  driver:
    image: my_robot:latest
    # No network_mode: host, no CYCLONEDDS_URI — topics invisible to other containers
  perception:
    image: my_robot_perc:latest

✅ Correct — explicit peer list via CycloneDDS config mounted into every container:

x-dds: &dds
  environment:
    RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
    CYCLONEDDS_URI: file:///cyclonedds.xml
  volumes:
    - ./config/cyclonedds_bridge.xml:/cyclonedds.xml:ro

services:
  driver:
    <<: *dds
    image: my_robot:latest
  perception:
    <<: *dds
    image: my_robot_perc:latest

No cache busting when apt sources go stale

❌ Wrong — no cache busting means apt-get update is skipped on cache hit, leading to stale package lists and 404 Not Found errors for packages:

RUN apt-get install -y python3-colcon-common-extensions

✅ Correct — always pair apt-get update and apt-get install in a single RUN layer, and use --no-install-recommends to keep image small:

RUN apt-get update && apt-get install -y --no-install-recommends \
    python3-colcon-common-extensions \
    python3-rosdep \
    && rm -rf /var/lib/apt/lists/*

For CI where freshness matters, use --no-cache on the build command:

docker build --no-cache --target deps -t my_robot:deps .

Using colcon build without --symlink-install in dev containers

❌ Wrong — full rebuild required after every Python script change when using bind mounts:

colcon build  # Copies files into install/; bind-mount source changes ignored

✅ Correct — use --symlink-install in dev containers so Python changes are reflected immediately:

colcon build --symlink-install
# Python files in install/ are now symlinks to src/; no rebuild needed for .py changes
# C++ still requires rebuild (compiled code cannot be symlinked)

Extended Configuration Reference

Docker Build Arguments

Build ArgDefaultDescription
ROS_DISTROhumbleROS2 distro (humble, iron, jazzy) — must match base image tag
UID1000User ID for non-root user — match host UID to avoid bind-mount permission issues
GID1000Group ID for non-root user
CMAKE_BUILD_TYPEReleaseRelease, Debug, RelWithDebInfo — use Debug for dev images
COLCON_PARALLEL_WORKERSCPU countLimit for constrained CI runners (e.g. --parallel-workers 2)

docker-compose Service Parameters

ParameterDescriptionExample
network_mode: hostShare host network namespace — DDS multicast works, no peer config needednetwork_mode: host
shm_sizeShared memory for image/pointcloud DDS transportshm_size: "512m"
devicesPass host device into container- /dev/ttyUSB0:/dev/ttyUSB0
group_addAdd container user to host group- dialout (serial), - video (camera)
privilegedFull host device access — avoid unless necessaryprivileged: false
deploy.resources.reservations.devicesNVIDIA GPU passthrough (requires NVIDIA Container Toolkit)driver: nvidia, count: 1, capabilities: [gpu]
restartAuto-restart policy for productionrestart: unless-stopped
healthcheckContainer health probetest: ["CMD", "ros2", "topic", "list"]
depends_onStart order and health dependencydepends_on: driver: condition: service_healthy

CycloneDDS XML Parameters

ParameterDescriptionRecommended Value
AllowMulticastEnable/disable multicast discoveryfalse for bridge networks
Peers/Peer addressExplicit peer by service name or IPService name in compose
WhcHighHigh-water mark for write history cache500kB–2MB for image topics
MaxMessageSizeUDP fragment size65500B (default)
ParticipantIndexDDS participant indexauto

GitHub Actions Cache Configuration

OptionDescription
cache-from: type=gha,scope=depsRead from GitHub Actions cache for the deps stage
cache-to: type=gha,mode=max,scope=buildWrite all layers to cache (max mode caches intermediate layers)
Separate scopes per stagePrevents a build cache miss from also busting the deps cache
Separate scopes per archscope=amd64 vs scope=arm64 for multi-arch builds

Extended Troubleshooting

SymptomCauseSolution
ros2 topic list empty inside containerDDS discovery failed across bridge networkAdd CycloneDDS peer list with service names; or switch to network_mode: host
Topics visible in one direction onlyAsymmetric peer listAdd peers in both directions; check ROS_DOMAIN_ID matches in all containers
GPU not accessible (CUDA error: no kernel image)NVIDIA Container Toolkit not installed or container not requesting GPUsudo apt install nvidia-container-toolkit && sudo systemctl restart docker; add deploy.resources.reservations.devices with capabilities: [gpu]
ARM64 cross-compile extremely slow in CIQEMU software emulation for ARM64Use runs-on: ubuntu-24.04-arm64 self-hosted runner, or AWS Graviton runner for native ARM64 builds
colcon test passes locally but fails in CINon-deterministic timing, missing env vars, or resource starvationSet ROS_DOMAIN_ID to an unused value (e.g. 99) in CI; add --timeout 120; allocate ≥2 CPU and ≥4 GB RAM to the runner
rosdep install fails with 404Stale apt cache in cached layerUse --no-cache for the deps stage in CI; or add --build-arg CACHE_BUST=$(date +%Y%m%d) weekly
Image bind mount shows wrong UID (permission denied)Host UID ≠ container user UIDBuild with --build-arg UID=$(id -u) --build-arg GID=$(id -g) to create a matching user
Container exits immediately after launchEntrypoint not sourcing setup.bash, or CMD not foundVerify entrypoint script has set -e and sources both /opt/ros/.../setup.bash and /ros2_ws/install/setup.bash; check CMD path
Large images (>4 GB) in CIDesktop image used, or build artifacts not cleanedUse ros-base or ros-core for runtime; run colcon build --cmake-clean-cache and delete build/ before copying to runtime stage
/dev/shm too small for point cloud topicsDefault 64 MB shm insufficientAdd shm_size: "512m" to compose service and enable <SharedMemory><Enable>true</Enable> in CycloneDDS XML

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.