Tools and scripts for setting up Docker on Ubuntu hosts and generating ready-to-use ROS 2 development images.
- Installing Docker
- Docker and the host filesystem owner matching problem
- builder — generating ROS 2 Docker images
- Launching graphical user interfaces (GUIs) in Docker containers
It is recommended to install Docker using the official Docker repository maintained by Docker, Inc., rather than using the default Ubuntu packages or Snap. This ensures you get the latest version of Docker with all features and security updates. If you already have Docker packages installed from the default Ubuntu repositories or via Snap, remove them and next install Docker from the official repository with the provided script install_docker.sh.
# Remove Docker packages installed via apt
dpkg -l | grep docker- | awk '{print $2}' | xargs -I% --no-run-if-empty sudo apt-get purge --auto-remove -y %
# Remove Docker packages installed via snap
snap list | grep docker | awk '{print $1}' | xargs -I% --no-run-if-empty sudo snap remove %Then use the scripts/install_docker.sh script provided in this repository. It configures your package manager to use the official Docker repository maintained by Docker, Inc., installs the latest Docker tools, and adds your user to the docker group:
bash scripts/install_docker.shAfter the script completes, log out and log back in (or restart) for the group membership to take effect.
If you see an error like:
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
your user is not yet in the docker group. Fix it with:
sudo usermod -aG docker $USERThen log out and back in, or run newgrp docker to apply the change to the current session.
UID stands for user identifier — a number assigned by the Linux kernel to each user. It is the actual identity used for access control: file ownership, process permissions, and resource access are all based on UIDs, not on usernames. Usernames are just human-readable labels that tools like ls translate from the underlying UID.
id
uid=1000(myuser) gid=1000(myuser) groups=1000(myuser),27(sudo),998(docker)UIDs 1–999 are typically reserved for system accounts. On Ubuntu, the first interactive user created during installation gets UID 1000.
Most Docker images only provide the root user (UID 0). Running as root inside a container is a security risk — a mistake as root has no safety net. Beyond security, there is a practical issue with bind mounts.
When you mount a directory from your host into a container (-v /host/path:/container/path), files created inside the container are owned by whatever UID is active in the container. If that UID does not match your UID on the host, you will not be able to edit or delete those files from your host OS without using sudo.
This is a well-known problem:
- Docker and the host filesystem owner matching problem
- Different file owner inside Docker container and in host machine
The images generated by this project do not hardcode a UID. Instead, the container starts as root, reads HOST_UID and HOST_UPGID from the environment, remaps the internal development user to those values at runtime, and then drops to that user. This means files created inside the container will always be owned by your host UID, regardless of what UID value that is.
See Running the container for how to pass HOST_UID and HOST_UPGID.
The builder/ directory contains the tooling to generate a complete Docker build context for a ROS 2 development image: a Dockerfile, a build.py script, a docker-compose-dev.yaml, and all supporting resources.
- Docker Engine
- Python 3.10+
- Python packages:
jinja2
If you intend to use an NVIDIA GPU:
- NVIDIA driver installed on the host (
nvidia-smiworks) - NVIDIA Container Toolkit
# See help:
python3 builder/create_docker_files.py -h
# Generate the build context:
python3 builder/create_docker_files.py myuser jazzy myorg/ros2-jazzy:latest --output ~/my_docker
# Optionally edit extra packages before building:
echo 'apt-get install -y --no-install-recommends ffmpeg' >> ~/my_docker/.resources/extra.d/apt_packages.sh
echo 'ruff==0.15.14' >> ~/my_docker/.resources/extra.d/requirements.txt
echo 'fd-find' >> ~/my_docker/.resources/extra.d/rust_packages.txt
# Build the image:
cd ~/my_docker
python3 build.py
# Build with ROS package dependencies resolved via rosdep:
python3 build.py --pkgs-dir /path/to/your/workspace/src
# Save the build log:
python3 build.py 2>&1 | tee /tmp/my_build.logusage: create_docker_files.py [-h] [-b BASE_IMG]
[--use-host-nvidia-driver] [--output OUTPUT]
image_main_user ros_distro img_id
| Argument | Description |
|---|---|
image_main_user |
Username for the development user inside the container |
ros_distro |
ROS distro: humble, jazzy |
img_id |
Docker image name and tag, e.g. myorg/ros2-jazzy:latest |
-b BASE_IMG |
Base Docker image. Default: ubuntu:X.Y matched to the ROS distro |
--use-host-nvidia-driver |
Enable NVIDIA GPU access via the host driver |
--output DIR |
Directory where the output is written. Default: a temporary directory under /tmp |
Available ROS distros:
humble- ROS 2, Ubuntu 22.04jazzy- ROS 2, Ubuntu 24.04
Custom base image:
You can pass any Docker image as the base, for example a CUDA image:
python3 builder/create_docker_files.py myuser jazzy myorg/ros2-jazzy:latest \
-b nvidia/cuda:12.5.0-devel-ubuntu24.04 \
--use-host-nvidia-driver \
--output ~/my_dockerusage: build.py [-h] [-c] [-p] [--pkgs-dir DIR] ...
| Argument | Description |
|---|---|
-c, --cache |
Reuse cached Docker layers |
-p, --pull |
Pull the latest base image before building |
--pkgs-dir DIR |
Path to the ROS packages directory on the host (e.g. ~/workspace/src). If provided, rosdep installs the dependencies of those packages into the image |
build.py writes directly to your terminal. Pipe through tee to keep a log file:
python3 build.py 2>&1 | tee /tmp/my_build.logAfter running create_docker_files.py, the output directory contains a
.resources/extra.d/ folder with three files you can edit before building:
Shell script executed as root after ROS is installed. Add apt packages, third-party repositories or any other system-level setup here.
#!/usr/bin/env bash
apt-get update
apt-get install -y --no-install-recommends libopencv-dev ros-jazzy-moveit
# Adding a third-party repository:
curl -sSL https://apt.llvm.org/llvm-snapshot.gpg.key | apt-key add -
add-apt-repository "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-18 main"
apt-get update && apt-get install -y clang-18If you generated without
--use-host-nvidia-driver, this file already contains the default Mesa packages script. Edit it as needed.
Standard pip requirements file. Installed as --user for the container user.
numpy
torch==2.3.0
--index-url https://download.pytorch.org/whl/cu121
torchvision
git+https://github.com/user/repo.git@main
One Rust crate per line. Two modes:
# Binary mode (fast): downloads a pre-built binary
ripgrep
fd-find
# Source mode (slow): compiles from source, use when features are needed
source: broot --features clipboard
If the file contains only comments or blank lines, the Rust toolchain is not installed.
When the container starts, the custom entrypoint runs all scripts found in ~/.entrypoint.d/ in alphabetical order. Scripts with a .sh extension are
sourced (they run in the same process and can set environment variables used by later scripts). Scripts with a .txt extension are printed to stdout.
Every file must follow the naming convention NN-name.sh or NN-name.txt, where NN is exactly two digits (e.g. 01, 50, 99). Files that do not match this pattern cause the container to abort at startup.
Two scripts are always included and are mandatory:
| Script | Purpose |
|---|---|
00-checks.sh |
Validates the naming convention of all files in entrypoint.d/ and checks that 99-uid-gid-adapt.sh is present. Runs first. |
99-uid-gid-adapt.sh |
Remaps the internal user UID/GID to match HOST_UID/HOST_UPGID and performs the final exec that starts the user session. Runs last. |
When --use-host-nvidia-driver is passed, an additional script is included:
| Script | Purpose |
|---|---|
98-gpu-driver-check.sh |
Warns at startup if the NVIDIA driver is not visible in the container (e.g. --gpus all was omitted or the NVIDIA Container Toolkit is not installed). Based on the upstream NVIDIA script. |
You can add custom scripts to .resources/entrypoint.d/ before running build.py. They will be copied into the image and executed at every container startup.
# Example: print a banner at startup
cat > .resources/entrypoint.d/10-banner.txt <<'EOF'
Welcome to my ROS 2 development container!
EOF
# Example: set custom environment variables at startup
cat > .resources/entrypoint.d/20-env.sh <<'EOF'
export MY_VAR=hello
EOFRules to follow:
- Filename must be
NN-name.shorNN-name.txtwith exactly two digits. - Do not use
00(reserved for checks) or99(reserved for UID/GID adaptation). - If
--use-host-nvidia-driverwas used, do not use98either. .shscripts are sourced — they run in the entrypoint process. Keep them fast and side-effect-free (noexit, no long-running commands).
This project always sets its own entrypoint (/usr/local/bin/entrypoint.sh), which overrides any entrypoint defined by the base image. If the base image you chose performs initialization logic that you want to preserve (common with NVIDIA images, for example), do not rely on the base entrypoint being called automatically.
Instead:
- Find the relevant script(s) in the base image entrypoint.
- Copy or adapt that logic into a new
.shfile in.resources/entrypoint.d/using an appropriate numeric prefix (e.g.10-nvidia-env.sh). - Run
build.pyas usual — the script will be picked up automatically.
To inspect what entrypoint a base image defines:
# Show the entrypoint declared in the image metadata:
docker inspect <base_img> --format '{{.Config.Entrypoint}}'
# Read the contents of that script:
docker run --rm --entrypoint cat <base_img> /path/to/entrypoint.shPass --use-host-nvidia-driver when generating. This configures the docker-compose file to use deploy.resources with the NVIDIA driver.
You also need to provide the GID of the render device so all processes (including those started by VS Code) can access /dev/dri/renderD*:
# Add the render device GID to a .env file next to docker-compose-dev.yaml:
echo "RENDER_GID=$(stat -c %g /dev/dri/renderD128)" >> .envThe output directory contains a docker-compose-dev.yaml. Copy it next to your workspace and create a .env file with the required variables:
# .env
HOST_UID=1000 # your UID: id -u
HOST_UPGID=1000 # your primary GID: id -g
WORKSPACE=/home/myuser/my_workspace # path to your workspace on the host
RENDER_GID=992 # required if --use-host-nvidia-driver was used: stat -c %g /dev/dri/renderD128Then:
docker compose -f docker-compose-dev.yaml upThe container starts as root, remaps the internal user to your HOST_UID/HOST_UPGID, and then drops to the development user. Files created inside the container will be owned by you on the host.
The builder/examples/ directory contains reference scripts for installing specific Mesa driver variants (default, Kisak PPA, Oibaf PPA, locked versions). These are not used automatically, copy the relevant parts into your
extra.d/apt_packages.sh.
Running GUI applications inside a Docker container requires giving the container access to the host's display server.
IMPORTANT: This graphical setup has been tested on X11 and is not currently validated on Wayland hosts.
On a Wayland host you may need XWayland — the compatibility layer that allows X11 applications to run inside a Wayland session. On Ubuntu it is usually provided by the xwayland package and started automatically by most desktop sessions when needed.
The generated docker-compose-dev.yaml already mounts /tmp/.X11-unix and forwards DISPLAY, which covers the X11 transport layer. What remains is telling the X server on the host to allow connections from the container process.
Run this command on the host before starting the container:
xhost +SI:localuser:$(id -un)This grants the current user's X11 access token to local connections without opening the display to everyone. When you are done with the container, revoke it:
xhost -SI:localuser:$(id -un)This approach works immediately with no extra configuration to docker-compose-dev.yaml. It is the simplest way to test GUI applications. The downside is that you need to run it every time you open a new session or restart the machine.
A more robust approach uses XAuth cookies: a file containing authentication tokens that the X server accepts. The scripts/install_docker_gui_support.sh script sets this up once on the host:
bash scripts/install_docker_gui_support.shThe script:
- Installs
/usr/local/bin/set_xauth_cookies.sh, which generates${XDG_RUNTIME_DIR}/cookies.xauth— a file containing the X11 authentication tokens for the current session. - Installs and enables a systemd user service (
set-xauth-cookies.service) that runs the above script automatically every time a graphical session starts, viagraphical-session.target. This works across all major desktop environments (GNOME, KDE, XFCE, etc.). - Runs the script immediately so GUI support is available without requiring a re-login.
After installation, the service can be managed with:
systemctl --user restart set-xauth-cookies # force cookie regeneration
systemctl --user status set-xauth-cookies # check current status
journalctl --user -u set-xauth-cookies # view logsTo use this approach with your container, add the following volume to your docker-compose-dev.yaml:
volumes:
- ${XDG_RUNTIME_DIR}/cookies.xauth:/tmp/.cookies.xauth:rwAnd add these environment variables:
environment:
- XAUTHORITY=/tmp/.cookies.xauth
- QT_X11_NO_MITSHM=1The generated docker-compose-dev.yaml does not include this volume by default because the choice between xhost and the cookie service is left to the user. Add it manually once you decide to use Option 2.
Note:
${XDG_RUNTIME_DIR}is a per-user private directory managed by systemd-logind (typically/run/user/<UID>). Each user on the host has their own cookie file, so multiple users can run containers simultaneously without conflicts.