Part
4
  |  
Connected Systems
  |  
Chapter
15

Going Headless

Most engineers plug a monitor into their Pi the moment it arrives — and never unplug it. That monitor is the single biggest obstacle between your Pi and production.
Reading Time
10
mins
BACK TO RASPBERRY PI MASTERCLASS

The trap is the desktop. You unbox a Raspberry Pi, flash the full Raspberry Pi OS with Desktop, connect an HDMI cable, plug in a keyboard and mouse, and start clicking around a Linux desktop that looks like a budget Chromebook. Everything works. You open the terminal, run your Python scripts, configure WiFi through the taskbar, and feel productive. Then you try to deploy. You realize the Pi needs to sit in a closet, or inside an enclosure, or on a shelf in a server room — anywhere a monitor doesn't belong. And because you built everything assuming a graphical interface, half your workflow breaks the moment the screen goes dark.

If your Pi has a monitor plugged in, it's a workstation, not a production device.

If your Pi has a monitor plugged in, it's a workstation, not a production device. The desktop environment consumes 200–400 MB of RAM before you run a single line of your code. It launches a window manager, a file manager, a browser, a notification daemon, and a compositor — none of which serve any purpose on a device that monitors a temperature sensor in a warehouse. Worse, the desktop introduces failure modes that don't exist on a headless system: X11 crashes, screen blanking that triggers power-saving modes on attached displays, and GPU memory allocation that steals from the CPU.

I run every production Pi headless. No desktop. No monitor. No keyboard. SSH in, do the work, SSH out. The Pi boots in eight seconds instead of thirty-five, uses half the RAM, exposes half the attack surface, and runs for months without anyone looking at it. That is the goal.

Raspberry Pi OS Lite: The Production Choice

Raspberry Pi OS ships in two versions that matter: Desktop and Lite. Desktop includes the full PIXEL desktop environment, Chromium, Thonny, and hundreds of packages you'll never use on a headless device. Lite is a minimal Debian image with a command line, systemd, and nothing else. Every package you need, you install explicitly.

Framework · The Headless Default · HD

Every Raspberry Pi you deploy should boot to a terminal and accept SSH connections. The desktop is for development; the command line is for production. If you're reaching for a monitor cable, you're troubleshooting a deployment problem, not running a production system.

The resource difference is not trivial. On a Pi 4 with 2 GB of RAM, the Desktop image idles at 350–450 MB of memory consumption. Lite idles at 50–80 MB. That's 300 MB you get back for your application — enough to run a Flask server, an MQTT broker, and a sensor-polling daemon simultaneously. On a Pi Zero 2W with 512 MB total, the Desktop image leaves you with barely enough headroom to run a Python script that imports NumPy.

✕ Desktop Image
  • 350-450 MB RAM at idle
  • 35-second boot time
  • X11, compositor, browser running
  • Larger attack surface
  • SD card fills faster with logs and caches
✓ Lite Image
  • 50-80 MB RAM at idle
  • 8-12 second boot time
  • Nothing running you didn't start
  • Minimal attack surface
  • Lean filesystem, longer SD card life

Flash Lite. Always. If you need a desktop for initial development, do that development on your laptop via SSH. The Pi is the deployment target, not the development environment.

The Boot-to-SSH Workflow

The Raspberry Pi Imager tool solved the most annoying step in headless setup. Before it existed, you had to flash an image, mount the boot partition, manually create configuration files, and hope you got the WiFi password right. Now you configure everything before the SD card leaves your laptop.

Open Raspberry Pi Imager, select Raspberry Pi OS Lite (64-bit), click the gear icon, and configure:

# What the Imager sets for you in the boot partition:
# 1. Hostname (e.g., pi-sensor-01.local)
# 2. SSH enabled with your public key
# 3. WiFi SSID and password (WPA2)
# 4. Locale and timezone
# 5. Username and password (no more default "pi" user)

The critical setting is SSH. Enable it, and paste your public key from ~/.ssh/id_ed25519.pub on your development machine. Password-based SSH is a liability — disable it from day one. If you don't have an SSH key yet:

# Generate an Ed25519 key pair (do this on your laptop, not the Pi)
ssh-keygen -t ed25519 -C "your-email@example.com"

# Copy the public key (you'll paste this into Raspberry Pi Imager)
cat ~/.ssh/id_ed25519.pub
Why Ed25519 over RSA

Ed25519 keys are shorter, faster to generate, and cryptographically stronger than 2048-bit RSA keys. Every modern SSH client supports them. There's no reason to generate RSA keys for new deployments in 2026.

Once the SD card is flashed and inserted, power on the Pi. Give it thirty seconds to boot and connect to WiFi. Then, from your laptop:

# Connect using the hostname you configured
ssh hesham@pi-sensor-01.local

# If .local resolution fails, find the IP with a network scan
# On macOS:
arp -a | grep -i "b8:27:eb\|dc:a6:32\|d8:3a:dd\|2c:cf:67"

# Or use nmap (install with brew install nmap)
nmap -sn 192.168.1.0/24 | grep -B2 "Raspberry"

That's it. No monitor. No keyboard. The Pi boots, connects to the network, and waits for you to SSH in. Every subsequent step happens over the network.

Key takeaway

The Raspberry Pi Imager configures SSH, WiFi, hostname, and user credentials before first boot. You should never need a monitor to set up a Pi.

systemd: The Service Manager You Already Know

Every long-running process on the Pi should be a systemd service. Not a script running in a tmux session. Not a cron job that restarts a Python process every five minutes. A proper systemd unit file that starts on boot, restarts on failure, logs to the journal, and can be managed with three commands.

Here is a real unit file for a sensor-monitoring daemon:

# /etc/systemd/system/sensor-monitor.service
[Unit]
Description=Temperature and humidity sensor monitor
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=hesham
WorkingDirectory=/home/hesham/sensor-monitor
ExecStart=/home/hesham/sensor-monitor/venv/bin/python monitor.py
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

The four commands you need to know:

# Install and start the service
sudo systemctl enable sensor-monitor.service   # start on boot
sudo systemctl start sensor-monitor.service    # start now

# Check status and logs
sudo systemctl status sensor-monitor.service   # is it running?
journalctl -u sensor-monitor.service -f        # tail the logs

enable tells systemd to start the service at boot. start starts it right now. status shows whether it's running, how long it's been up, and the last few log lines. journalctl -f tails the log output in real time — the same as tail -f on a log file, but centralized across every service.

PYTHONUNBUFFERED is non-negotiable

Python buffers stdout by default. Without PYTHONUNBUFFERED=1, your print statements and logging calls won't appear in journalctl until the buffer flushes — which might be minutes later, or never if the process crashes. Set this environment variable in every Python service unit file.

The Restart=on-failure and RestartSec=10 lines are the production safety net. If your Python process crashes — an unhandled exception, a segfault in a C extension, a kernel OOM kill — systemd waits ten seconds and restarts it. No human intervention. No monitoring script. The init system does its job.

Persistent Sessions and Remote File Management

SSH connections die. Your laptop sleeps. Your WiFi drops. When the connection breaks, any process running in your SSH session dies with it — unless you're running inside tmux or screen.

# Install tmux (one-time)
sudo apt install tmux

# Start a named session
tmux new -s deploy

# Detach from the session (it keeps running)
# Press Ctrl+B, then D

# Reconnect later (even from a different machine)
tmux attach -t deploy

# List active sessions
tmux ls

I use tmux for two specific scenarios: long-running installations (apt upgrade on a slow connection) and interactive debugging sessions where I need the process to survive a dropped connection. For everything else — for actual production workloads — the answer is systemd services, not tmux sessions.

tmux is for debugging sessions that need to survive a dropped connection. systemd is for production processes that need to survive a reboot.

For file management, scp and rsync handle most transfers:

# Copy a file to the Pi
scp monitor.py hesham@pi-sensor-01.local:~/sensor-monitor/

# Sync an entire project directory
rsync -avz --exclude 'venv/' --exclude '__pycache__/' \
  ./sensor-monitor/ hesham@pi-sensor-01.local:~/sensor-monitor/

# Copy a file from the Pi to your laptop
scp hesham@pi-sensor-01.local:~/sensor-monitor/data.csv ./

For a more integrated workflow, VS Code's Remote-SSH extension turns the Pi into a remote development environment. You edit files on the Pi using your laptop's VS Code instance, with full IntelliSense, terminal access, and file browsing. The experience is indistinguishable from local development — the only difference is the code runs on the Pi, with access to GPIO and connected sensors.

Security Hardening: The Non-Negotiable Checklist

A headless Pi is, by definition, a networked device with an SSH daemon. If you skip basic hardening, you're running an always-on Linux server with default settings that are documented in thousands of tutorials — which means every script kiddie on the internet knows what to try.

The first step is disabling password authentication (covered in the boot-to-SSH section above). Key-based auth only. The second step is keeping the system updated:

# Update packages weekly — add this as a systemd timer
sudo apt update && sudo apt upgrade -y

The third step is enabling a firewall. ufw (Uncomplicated Firewall) ships in the Raspberry Pi OS repositories and takes thirty seconds to configure:

sudo apt install -y ufw

# Allow SSH (essential — lock yourself out and you need a monitor)
sudo ufw allow ssh

# Allow your application port (e.g., Flask API on 5000)
sudo ufw allow 5000/tcp

# Enable the firewall
sudo ufw enable

# Verify
sudo ufw status

The fourth step is disabling services you don't need. A fresh Raspberry Pi OS Lite installation is already lean, but check for anything listening on ports you didn't expect:

sudo ss -tlnp

If you see something listening that you didn't configure, investigate before deploying the Pi to production. Every open port is a potential attack vector on a device that might run unattended for months.

I've seen this pattern where an engineer deploys a Pi with the default pi user and a simple password, puts it on a network with port forwarding for remote access, and discovers six months later that the Pi has been recruited into a botnet. The fix is trivial — key-only SSH, a firewall, regular updates — but it has to happen before deployment, not after the compromise.

Static IP and mDNS: Finding Your Pi Reliably

DHCP assigns a different IP address every time your Pi reconnects to the network. That's fine for a laptop. It's unacceptable for a device you need to reach at a predictable address.

Two solutions, use both:

mDNS (raspberrypi.local) works out of the box on Raspberry Pi OS. The Pi announces itself as <hostname>.local on the local network using Avahi (the Linux mDNS daemon). From any Mac or Linux machine — and from Windows 10+ — you can reach the Pi by hostname without knowing its IP:

ssh hesham@pi-sensor-01.local

mDNS is convenient but unreliable across subnets, VLANs, and some corporate networks that block multicast traffic. For anything beyond a single flat network, assign a static IP.

Static IP via dhcpcd (the traditional method on Raspberry Pi OS):

# /etc/dhcpcd.conf — append these lines
interface wlan0
static ip_address=192.168.1.50/24
static routers=192.168.1.1
static domain_name_servers=192.168.1.1 8.8.8.8
# Apply the change
sudo systemctl restart dhcpcd

Or, on newer Raspberry Pi OS versions using NetworkManager:

# Set a static IP via nmcli
sudo nmcli con mod "preconfigured" \
  ipv4.addresses 192.168.1.50/24 \
  ipv4.gateway 192.168.1.1 \
  ipv4.dns "192.168.1.1,8.8.8.8" \
  ipv4.method manual

sudo nmcli con up "preconfigured"
Reserve the IP on your router too

A static IP on the Pi side prevents the Pi from requesting a different address. A DHCP reservation on the router side prevents the router from offering that address to another device. Do both, or you'll eventually get an IP conflict that takes down both devices.

Cron vs systemd Timers

Cron is the tool every Linux user reaches for when they need to run something on a schedule. It works. But systemd timers are better for anything that matters, because they integrate with the journal (centralized logging), support calendar-based and monotonic schedules, can depend on other services, and survive the kind of edge cases that cron silently mishandles — like a scheduled job running twice after a clock adjustment.

# /etc/systemd/system/sensor-report.service
[Unit]
Description=Generate hourly sensor report

[Service]
Type=oneshot
User=hesham
WorkingDirectory=/home/hesham/sensor-monitor
ExecStart=/home/hesham/sensor-monitor/venv/bin/python report.py
# /etc/systemd/system/sensor-report.timer
[Unit]
Description=Run sensor report every hour

[Timer]
OnCalendar=*-*-* *:00:00
Persistent=true

[Install]
WantedBy=timers.target
# Enable and start the timer
sudo systemctl enable sensor-report.timer
sudo systemctl start sensor-report.timer

# Verify it's scheduled
systemctl list-timers --all | grep sensor

The Persistent=true line is the critical difference from cron. If the Pi was powered off when a scheduled run was supposed to fire, systemd runs the job immediately on the next boot. Cron would silently skip it. For hourly sensor reports, that missed run might mean a gap in your data that nobody notices for days.

Key takeaway

systemd services for long-running daemons, systemd timers for scheduled tasks, tmux for interactive debugging. Cron is acceptable for trivial scripts; for anything that touches production data, use systemd timers with Persistent=true.

What to Do Monday Morning

Flash a Pi with Raspberry Pi OS Lite

Download Raspberry Pi Imager, select Lite (64-bit), configure SSH with your public key, set a hostname, and configure WiFi — all before the SD card leaves your laptop. Boot the Pi with no monitor attached.

SSH in and disable password authentication

Edit /etc/ssh/sshd_config, set PasswordAuthentication no, and restart sshd. Verify you can still connect with your key. This closes the most common attack vector on exposed Pi boards.

Convert one running script to a systemd service

Take any Python script you currently run manually or in a tmux session. Write a unit file for it. Enable it. Reboot the Pi and confirm the service started automatically with systemctl status.

Set a static IP and verify mDNS

Configure a static IP via dhcpcd or NetworkManager. Reserve the same IP on your router. Test that both ssh user@hostname.local and ssh user@192.168.1.50 reach the same Pi.

Replace one cron job with a systemd timer

If you have any cron-scheduled tasks, convert the most important one to a systemd timer with Persistent=true. Check systemctl list-timers to confirm it's registered.

A headless Pi is a production Pi. Everything else is a workstation pretending to be infrastructure.