From a blank SD card to ROS2 Jazzy: standing up Elliot's Raspberry Pi 5
The from-scratch setup of Elliot's main compute — flashing Ubuntu 24.04 Server, the mDNS and direct-ethernet DDS plumbing that makes two Pis talk, the swap and USB-current tweaks a Pi 5 actually needs, and a clean ROS2 Jazzy install.
Elliot is a robot I’m building in my spare time — a Kobuki base, a pair of Raspberry Pi 5s, a LIDAR, a depth camera, a speaker it talks through, and a ring of light around its crown that does the emoting. I’ve written before about the ring, which is the part visitors notice first. This post is about the part nobody notices and everything else stands on: the main compute board, elliot-core, going from a blank SD card to a machine that runs ROS2 and can be driven headless over the network.
It’s the first in a short series on bringing the robot up from nothing. Later posts add the Kobuki driver, the RPLIDAR, the OAK-D Lite camera, SLAM, and Nav2 — but all of that assumes a Pi that boots, that you can reach without a monitor plugged in, and that has a working ROS2 install underneath it. That’s what this one covers, with every command, so you can follow it on your own board.
Why this stack
Two choices are worth a sentence each before the keyboard work, because they shape everything downstream.
ROS2 Jazzy over Humble. Humble is the older, very widely deployed LTS, but it targets Ubuntu 22.04, and the Raspberry Pi 5 wants 24.04. Jazzy is the LTS that natively targets Noble, it’s supported until May 2029, and — the reason that actually settled it — the Kobuki packages I need have explicit Jazzy support. Picking the distro that matches your hardware’s OS means no backported kernels and no community workarounds to babysit.
Ubuntu Server over Desktop. The Pi is a headless robot brain. It will never have a monitor bolted to it in normal operation; I reach it over SSH. Server gives me a smaller image, fewer background services competing for the four cores, and no desktop GPU stack burning RAM that ROS2 nodes could use. I install ros-base — the headless ROS2 metapackage — for the same reason. Visualization tools like RViz2 run on the other Pi (the one with the touchscreen) or on my Mac, never here.
With that decided, here’s the shape of the network you’re building toward, because one piece of it is non-obvious:
Both Pis sit on home WiFi for internet and package installs. But they also talk to each other over a direct ethernet cable on a private 10.0.0.0/24 subnet — no router, no DHCP, just static IPs — because ROS2’s pub/sub traffic between the two boards should not be paying WiFi’s latency and jitter tax. We’ll wire that up below. For this article only elliot-core exists; elliot-comms joins in a later post.
Flashing Ubuntu 24.04 Server
Everything starts in the Raspberry Pi Imager. Install it on your workstation, insert the SD card (32GB minimum, 64GB is comfortable — you’ll be compiling large packages), and set three things:
- Device: Raspberry Pi 5
- OS: Other general-purpose OS → Ubuntu → Ubuntu Server 24.04 LTS (64-bit)
- Storage: your SD card
Then — and this is the step that saves you an afternoon of monitor-and-keyboard fiddling — click the gear icon for OS customisation before writing. Pre-seed the headless setup:
- Hostname:
elliot-core - Enable SSH, password authentication for now
- Username and password (I’ll write the user as
<user>throughout; substitute whatever you chose) - WiFi: join your network. Use the 2.4GHz band if you can — it trades throughput for range, and a robot that roams to the far end of the house cares more about not dropping off the network than about bandwidth
- Locale and timezone
Write the card, drop it in the Pi, and power on. Because you pre-seeded WiFi and SSH, the board comes up on the network by itself with nothing plugged into it but power.
First contact: hostname, mDNS, and SSH keys
Find the Pi’s address (your router’s client list, or arp -a) and SSH in by IP this one time:
ssh <user>@<IP_ADDRESS>
The goal of this section is to never type that IP again. DHCP leases move; hostnames don’t. First, make the hostname stick and confirm /etc/hosts agrees with it:
# Set the hostname (the Imager may have already done this; this is the canonical way)
sudo hostnamectl set-hostname elliot-core
# Point the loopback entry at the new name
sudo sed -i 's/127\.0\.1\.1.*/127.0.1.1\telliot-core/' /etc/hosts
Now make the Pi discoverable by that name. mDNS (Apple calls it Bonjour, the Linux implementation is Avahi) lets a machine answer to <hostname>.local on the local network with zero DNS configuration. Macs speak it natively, so once the Pi advertises itself there’s nothing to set up on the workstation side:
# Install Avahi (Server images don't always ship it)
sudo apt install -y avahi-daemon avahi-utils
sudo systemctl enable avahi-daemon
sudo systemctl restart avahi-daemon
# Confirm the Pi is advertising itself
avahi-resolve-host-name elliot-core.local
From the workstation you can now reach the Pi by name, lease changes be damned:
ping elliot-core.local
ssh <user>@elliot-core.local
Two small quality-of-life moves make this permanent. First, an SSH config entry on the workstation (in ~/.ssh/config) so the connection is just ssh elliot-core:
Host elliot-core
HostName elliot-core.local
User <user>
Host elliot-comms
HostName elliot-comms.local
User <user>
(I’ve added the elliot-comms block now even though that Pi doesn’t exist yet — future me will thank present me.)
Second, key-based auth, so you stop typing the password on every connection. Run this on the workstation:
# Generate a key if you don't already have one
ssh-keygen -t ed25519 -C "workstation"
# Copy the public half up to the Pi (asks for the password one last time)
ssh-copy-id <user>@elliot-core.local
After that, ssh elliot-core connects with no prompt. Worth doing before anything else, because the rest of this setup involves a lot of reconnecting.
First-boot housekeeping
Fresh Ubuntu Server runs unattended upgrades in the background on first boot. If you start apt-ing immediately you’ll collide with it and get lock errors, so check whether it’s still going before you do anything:
ps aux | grep unattended
Once it’s clear, bring the system fully up to date and patch any half-finished package state:
sudo apt update && sudo apt --fix-broken install && sudo apt upgrade -y
Then install the tools you’ll lean on constantly — the build chain for compiling ROS2 packages from source later, plus the basics:
sudo apt install -y \
curl \
gnupg2 \
lsb-release \
build-essential \
cmake \
git \
vim \
htop \
python3-pip \
software-properties-common
Finally, verify the locale. ROS2 is particular about a UTF-8 locale, and a misconfigured one produces baffling errors deep in unrelated tools:
locale
# If anything looks off, set it explicitly:
sudo apt install -y locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
export LANG=en_US.UTF-8
Swap, because compiling will run you out of RAM
This step looks optional and isn’t. Several of the packages in later posts — the Kobuki driver, parts of the navigation stack — get compiled on the Pi itself, and a parallel C++ build of that size will exhaust even an 8GB Pi 5’s RAM and get processes killed by the OOM reaper mid-build. An 8GB swap file is the cheap insurance:
# See what you're working with
free -h
# Create an 8GB swap file
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Make it survive reboots
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# Confirm it's active
free -h
Swap on an SD card is slow, and you wouldn’t want a server leaning on it under steady load — but as headroom that keeps a once-in-a-while compile from dying, it’s exactly the right tool.
The direct-ethernet link between the two Pis
This is the non-obvious piece of the network from the diagram up top, and it’s worth understanding even if you’re following along with a single Pi (you can skip the commands and come back when you add a second board).
Elliot has two Pi 5s: elliot-core runs the sensors and navigation, elliot-comms runs voice and the display. They coordinate entirely over ROS2 topics, which under the hood is a chatty pub/sub protocol called DDS. Running that traffic over WiFi works, but WiFi adds 2–10ms of latency with unpredictable jitter, and for control-loop messages flying between the two boards that’s a tax you don’t want to pay. So the Pis are wired together directly with a short ethernet cable on their own private subnet. WiFi stays for internet; ethernet carries the robot’s internal nervous system.
No router or DHCP server sits on that link — it’s two machines, so you just assign static addresses. On Ubuntu that’s a Netplan file:
sudo nano /etc/netplan/99-ethernet-link.yaml
network:
version: 2
ethernets:
eth0:
addresses:
- 10.0.0.1/24
Netplan is picky about file permissions and will warn loudly if they’re too open, so lock the file down before applying:
sudo chmod 600 /etc/netplan/99-ethernet-link.yaml && sudo netplan apply
# Once elliot-comms is configured with 10.0.0.2, this will answer:
ping 10.0.0.2
elliot-comms later gets 10.0.0.2/24 the same way. But static IPs only get the two boards onto the same wire — they don’t get ROS2’s traffic onto it, and this is the part the “just plug in a cable” instinct gets wrong. DDS finds and uses every network interface it can see. That sounds helpful until you remember both Pis are also on WiFi: left to its defaults, Fast DDS (Jazzy’s default middleware) will discover the two boards over WiFi and cheerfully run their traffic there, and you’ll have built a low-latency link that carries nothing. To make the robot’s internal traffic actually ride the wire, you have to tell DDS which interface to use.
That’s a Fast DDS profile — the same FASTRTPS_DEFAULT_PROFILES_FILE mechanism the comms board uses to wrangle DDS elsewhere. Create it on elliot-core:
nano ~/elliot_dds_profile.xml
<?xml version="1.0" encoding="UTF-8" ?>
<dds>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<transport_descriptors>
<transport_descriptor>
<transport_id>ethernet_only</transport_id>
<type>UDPv4</type>
<interfaceWhiteList>
<address>10.0.0.1</address>
<address>127.0.0.1</address>
</interfaceWhiteList>
</transport_descriptor>
</transport_descriptors>
<participant profile_name="participant_profile" is_default_profile="true">
<rtps>
<userTransports>
<transport_id>ethernet_only</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
</profiles>
</dds>
The whitelist is the whole point: it confines DDS to the 10.0.0.1 ethernet address and the 127.0.0.1 loopback, and nothing else — WiFi included. Switching off the built-in transports (useBuiltinTransports=false) is what makes that binding stick; leave them on and DDS keeps a default transport running on every interface, and the WiFi path stays open behind your back. Loopback is on the list deliberately: without it, two nodes on the same board — like the talker and listener you’ll run at the end of this post — would have no interface left to find each other on.
One thing not to do yet, though: don’t point your shell at this profile until the ethernet link is actually live. A whitelist only binds to the interfaces in it that genuinely exist and are up — and an ethernet port with no cable in it (no elliot-comms on the other end yet) is down, which leaves only loopback. Loopback can’t carry the multicast that DDS discovery relies on, so a profile enabled too early would leave even two nodes on one board unable to find each other — the exact quiet failure this is meant to prevent, now self-inflicted. So on a single board today, skip the next command and stay on the default configuration; the talker/listener test at the end of this post runs on it.
Once elliot-comms is built and the cable is in, enable the profile by adding it to your shell (it lives in ~/.bashrc, alongside the ROS2 lines from the next section):
echo "export FASTRTPS_DEFAULT_PROFILES_FILE=$HOME/elliot_dds_profile.xml" >> ~/.bashrc
source ~/.bashrc
elliot-comms gets the identical profile with 10.0.0.2 swapped in for 10.0.0.1. With both boards configured and linked, their ROS2 traffic rides the ethernet and never touches WiFi.
Two honest caveats once it’s on. First, the whitelist confines all of that Pi’s ROS2 traffic to the wire and loopback — so a workstation on WiFi (your Mac) won’t see the robot’s topics anymore. When you want RViz on the Mac, put it on the robot’s network (a USB-ethernet adapter onto the same link, say) or run the tool on one of the Pis; a later post on the workstation setup gets into this properly. Second, “configured” isn’t “confirmed” — once comms is online, check the traffic is genuinely on the wire by watching the byte counters climb on eth0, not wlan0, while a topic streams:
watch -n1 'cat /sys/class/net/eth0/statistics/tx_bytes /sys/class/net/wlan0/statistics/tx_bytes'
One Pi 5 firmware tweak: maximum USB current
Here’s a Pi-5-specific gotcha that you want to fix now, before it bites a later post. The OAK-D Lite depth camera (a future article) draws more current than the Pi 5’s default USB budget permits the moment its onboard vision processor spins up. With the default budget, the camera enumerates, starts, and then instantly dies with Device already closed or disconnected — a failure that looks like a broken camera or a bad cable and is neither.
The fix is a one-line firmware setting that lifts the USB current cap. Add it under the [all] section of /boot/firmware/config.txt:
usb_max_current_enable=1
Then reboot to apply it:
sudo reboot
I’m calling it out in the foundation post precisely because it’s invisible until it isn’t. Setting it on a clean install costs nothing; debugging a phantom camera crash three articles from now costs an evening.
Installing ROS2 Jazzy
With the OS sorted, the ROS2 install itself is straightforward. First, add the official ROS2 apt repository. That means enabling Ubuntu’s “universe” component, importing the ROS2 signing key, and registering the repo:
# ROS2 packages live in Ubuntu's universe component
sudo add-apt-repository universe
# Import the ROS2 archive signing key
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
-o /usr/share/keyrings/ros-archive-keyring.gpg
# Register the repo (the $UBUNTU_CODENAME expands to "noble" on 24.04)
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
sudo apt update
That repo line is doing something quietly clever: it reads $UBUNTU_CODENAME out of /etc/os-release, so the same command produces the right repo on whatever Ubuntu version it runs on. On a 24.04 box it resolves to noble, which is exactly the pairing — Noble OS, Jazzy packages — from the top of the post.
Now install ROS2 itself. ros-jazzy-ros-base is the headless metapackage — the communication core and command-line tooling, no GUI:
# Headless ROS2 — the right choice for a robot brain
sudo apt install -y ros-jazzy-ros-base
# Build and development tooling, including colcon
sudo apt install -y ros-dev-tools python3-colcon-common-extensions
# rosdep resolves package dependencies for source builds later
sudo rosdep init
rosdep update
While we’re here, install a handful of packages the later posts depend on — teleoperation, joystick support, and the robot-description tooling that SLAM and Nav2 need:
sudo apt install -y \
ros-jazzy-teleop-twist-keyboard \
ros-jazzy-joy \
ros-jazzy-joy-teleop \
ros-jazzy-teleop-twist-joy \
ros-jazzy-diagnostic-updater \
ros-jazzy-angles \
ros-jazzy-robot-state-publisher \
ros-jazzy-xacro
Wiring ROS2 into your shell
A ROS2 install does nothing until you “source” its setup script, which loads the environment — paths, the executables, the message definitions — into your shell. You could type that on every login; instead, put it in ~/.bashrc so every new shell is ROS2-ready:
echo "" >> ~/.bashrc
echo "# ROS2 Jazzy" >> ~/.bashrc
echo "source /opt/ros/jazzy/setup.bash" >> ~/.bashrc
# All machines that should see each other's topics must share a domain ID.
# Keep it in the 0–101 range (past 101 you risk colliding with the OS's
# ephemeral ports), and use the same value on elliot-core and elliot-comms.
echo "export ROS_DOMAIN_ID=42" >> ~/.bashrc
# Tab-completion for colcon, the ROS2 build tool
echo "source /usr/share/colcon_argcomplete/hook/colcon-argcomplete.bash" >> ~/.bashrc
# Load it into the current shell without re-logging-in
source ~/.bashrc
ROS_DOMAIN_ID is the one to be deliberate about. DDS uses it to partition the network — nodes only discover peers that share their domain ID. Set both Pis to the same value (I use 42) and they form one robot; mismatch them and the boards are on the same wire but invisible to each other, with no error to tell you why. It also keeps Elliot from accidentally discovering some other ROS2 machine on the network.
Confirm the install is healthy:
ros2 doctor --report
Expect a warning or two — usually about network interfaces or the default RMW — and don’t let them rattle you; on a fresh install those are normal, and it’s errors you actually care about. Past those, a clean report means the environment, the middleware, and the package set all agree.
The “it’s alive” moment: talker and listener
ROS2 ships a pair of demo nodes whose only job is to prove the pub/sub plumbing works: a talker that publishes a string on a topic, and a listener that subscribes and prints what it hears. Running them across two shells is the canonical smoke test.
sudo apt install -y ros-jazzy-demo-nodes-cpp
Open two SSH sessions to the Pi (ssh elliot-core in two terminal tabs — this is where the earlier SSH-key work pays off). In the first:
ros2 run demo_nodes_cpp talker
In the second:
ros2 run demo_nodes_cpp listener
The talker prints Publishing: 'Hello World: 1', ... 2, ... 3, and the listener echoes I heard: [Hello World: 1] a beat behind it. Two independent processes, found each other through DDS discovery with no addresses configured, and a message crossed between them.
While the talker is still running, open a third session and meet the introspection tools you’ll live in for the rest of the series:
ros2 topic list # topics currently advertised — /chatter is the talker's
ros2 topic echo /chatter # print the messages as they're published
ros2 node list # running nodes — /talker and /listener
ros2 topic echo is the workhorse of the whole stack: point it at any topic and it prints the live message stream. When the LIDAR is publishing scans or the Kobuki base is reporting odometry in the posts ahead, this is how you confirm data is actually flowing before you trust anything built on top of it. Ctrl+C everything when you’ve seen enough.
That handshake is the whole point of the foundation. Everything in the posts that follow — the Kobuki driver publishing wheel odometry, the LIDAR streaming scans, the navigation stack issuing velocity commands — is that same talker-and-listener pattern with real payloads. If this works, the substrate works.
What I’d tell someone starting today
A few things worth carrying to your own build, in rough order of how much grief they save:
- Set up mDNS and SSH keys before anything else. The five minutes on Avahi, an
~/.ssh/configentry, andssh-copy-idturn the entire rest of the process from “what’s the IP today” intossh elliot-core. Do it first, not when you’re tired of typing passwords. - Server, not Desktop;
ros-base, not the full desktop install. A headless robot brain has no use for a GPU stack or a window manager. Spend the RAM and the cores on ROS2. - Match the distro to the OS and stop fighting it. Noble + Jazzy is the supported pairing for a Pi 5. Choosing the combination your hardware actually targets means no backports and no workarounds to maintain.
- Add swap even though the Pi has 8GB. It exists for one moment: the large compile in a later step that would otherwise get OOM-killed halfway through. You’re not running on it, just surviving a spike.
- A direct cable between boards is wasted until you pin DDS to it. Static IPs put the two Pis on the wire; an interface whitelist is what stops their traffic from wandering back onto WiFi. And don’t enable the whitelist until the link is actually up, or you’ll starve discovery on a single board.
- Set
usb_max_current_enable=1now. It costs one line on a clean install and saves you debugging a phantom USB peripheral crash much later. - Pin
ROS_DOMAIN_IDand keep it identical across machines. A domain-ID mismatch is the quietest failure in ROS2 — same network, same everything, mutually invisible, no error.
None of this is glamorous, and that’s the nature of a foundation: you only notice it when it’s wrong. But there’s a real moment in it — the first time listener echoes a message it received from a talker it was never introduced to, on a board you set up without ever plugging in a screen. That’s the robot’s nervous system coming online. The next post puts the Kobuki driver on top of it and makes Elliot move.