>_ ctrlaltbrian
Tutorials ESP32 Arduino Raspberry Pi Pico About Queue

ctrlaltbrian

Home Automation with Raspberry Pi

Turn a Raspberry Pi into the brain of your home network. Pi-hole, Mosquitto, Node-RED, Samba, and the dozen small services that turn a Pi into a home server.

12 chapters · ~6 hours · last updated 2026-09-24

$24

Chapter 01

Raspberry Pi: headless setup without a monitor

raspberry-pi · 25 min

Every Raspberry Pi project I do starts this way: I write an SD card with Raspberry Pi OS, configure Wi-Fi and SSH before the first boot, plug it in, and SSH into it from my laptop. No monitor, no keyboard, no fighting with HDMI cables.

This tutorial walks through the whole flow. If you have a Raspberry Pi you have never used, this is where to start.

What you need

  • Raspberry Pi (any model: 4, 5, Zero 2 W, etc.)
  • MicroSD card (32 GB or larger, A1 or A2 rated)
  • MicroSD card reader (most laptops have one, otherwise a USB adapter)
  • Power supply (official USB-C PSU for the Pi 4 and 5, micro-USB for the older models)
  • A computer with an SD card slot or reader
  • An ethernet cable or a known Wi-Fi network

Step 1: flash the SD card

Download the Raspberry Pi Imager from https://www.raspberrypi.com/software/.

Run the imager:

  1. Choose OS: Raspberry Pi OS (other) >> Raspberry Pi OS Lite (64-bit). The "Lite" version has no desktop and is what you want for headless projects. Use the full version only if you plan to plug in a monitor.
  2. Choose Storage: select your SD card.
  3. Click the gear icon (or Edit settings on the macOS version):
    • Set hostname (e.g. pi-pihole)
    • Enable SSH, set password
    • Set username and password
    • Configure Wi-Fi: SSID, password, country code
  4. Click Write.

The imager formats the card and writes the OS. This takes about 5 minutes on a fast card.

The "Lite" image is 700 MB. The full image is 3 GB. If you do not need a desktop, use Lite. The Pi is way more responsive over SSH without the desktop running.

Step 2: plug it in

  1. Insert the SD card into the Pi.
  2. Connect the Pi to your network (Wi-Fi is configured, or plug in ethernet).
  3. Plug in the power supply.

The Pi boots in about 20-30 seconds. The green LED on the Pi will flicker during boot.

Step 3: SSH in

From your laptop:

ssh brian@pi-pihole.local

(Replace brian with the username you set and pi-pihole with the hostname you set.)

If you are on a different network, find the IP address of the Pi:

  • Check your router's admin page (usually 192.168.1.1 or 192.168.0.1).
  • Use a network scanner like nmap:
nmap -sn 192.168.1.0/24
  • On macOS, the hostname will appear in Finder under "Network."

Once you find the IP:

ssh brian@192.168.1.42

Step 4: first-boot setup

The first time you SSH in, you should run:

sudo apt update
sudo apt upgrade -y
sudo raspi-config

In raspi-config, useful options:

  • Interface Options >> SSH (already enabled if you set it in the imager)
  • Interface Options >> VNC if you want a remote desktop
  • Performance Options >> GPU Memory to 16 MB if you are not using a desktop
  • Advanced Options >> Expand Filesystem (should happen automatically on modern OS images, but check)

Then exit raspi-config and reboot if it asks.

Step 5: lock down the basics

Set the timezone:

sudo timedatectl set-timezone America/Denver

(Replace with your timezone.)

Enable automatic security updates:

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Set up a static IP (optional, but helpful for headless servers):

Edit /etc/dhcpcd.conf:

interface wlan0
static ip_address=192.168.1.100/24
static routers=192.168.1.1
static domain_name_servers=1.1.1.1 8.8.8.8

Reboot:

sudo reboot

After the reboot, the Pi is at 192.168.1.100.

The mDNS gotcha

.local hostnames (e.g. pi-pihole.local) work via mDNS (also known as Bonjour). They work great on macOS and modern Linux. On Windows, you need to install the "Bonjour Print Services" or enable it via the iTunes installer. On older Windows builds, mDNS is unreliable.

If you are on Windows and .local does not work, use the IP address instead, or set up a static DHCP reservation in your router.

Setting up SSH keys (so you do not type a password every time)

On your laptop:

ssh-keygen -t ed25519
ssh-copy-id brian@pi-pihole.local

Or, if ssh-copy-id is not available:

cat ~/.ssh/id_ed25519.pub | ssh brian@pi-pihole.local "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

Then on the Pi, disable password authentication:

sudo nano /etc/ssh/sshd_config

Set PasswordAuthentication no. Save, exit, restart SSH:

sudo systemctl restart ssh

Now you can SSH in without typing a password, and password-based login is disabled (which closes a common attack vector).

When something goes wrong

  • The Pi is not reachable. Check the green LED on the Pi. If it is flickering, the Pi is booting. If it is off, the SD card or power supply is bad. If it is solid, the Pi booted but the network is not configured.
  • Wi-Fi password wrong. You will need to mount the SD card on your laptop and edit /etc/wpa_supplicant/wpa_supplicant.conf. Add the correct password and reboot.
  • SD card corrupted. Reflash it. This happens to about 1 in 20 cards eventually. Keep backups.

What to build next

  • A Pi-hole ad blocker (the reason I have a Pi running 24/7).
  • A NAS with Samba or NFS.
  • A Home Assistant server.

The Pi-hole tutorial is one of the next on this site. The Home Assistant version is in the book Home Automation with Raspberry Pi.


Chapter 02

Raspberry Pi: read a DHT22 with Python and the GPIO pins

raspberry-pi · 20 min

A Raspberry Pi reading a sensor is the foundation for home automation, greenhouse monitoring, weather stations, and about a hundred other projects. This tutorial covers the DHT22 specifically, but the wiring and Python pattern apply to most low-cost sensors.

What you need

  • Raspberry Pi (any model with GPIO pins)
  • DHT22 sensor (the breakout version with three pins)
  • 10k resistor (only if your DHT22 breakout does not have one)
  • Three jumper wires

Wiring

The DHT22 has three pins: VCC, data, and GND.

DHT22 pin Pi pin Pi header name
VCC Pin 1 3.3V
DATA Pin 7 GPIO 4
GND Pin 9 GND

The Pi uses BCM numbering for GPIO (so GPIO 4 is pin 7 on the header). There is a diagram at https://pinout.xyz if you need a visual.

The DHT22 should be powered from 3.3V, not 5V. The Pi's GPIO pins are 3.3V only; applying 5V will damage them.

Install the library

The Adafruit DHT library works on the Pi:

sudo apt install -y python3-pip
pip3 install --break-system-packages adafruit-circuitpython-dht

(--break-system-packages is needed on Raspberry Pi OS Bookworm and newer. The older system Python will not let you install without it.)

The Python script

import time
import board
import adafruit_dht

# GPIO 4 = pin 7 on the header
dht = adafruit_dht.DHT22(board.D4)

while True:
    try:
        temperature = dht.temperature
        humidity = dht.humidity
        print(f"Temp: {temperature:.1f} C  Humidity: {humidity:.1f} %")
    except RuntimeError as e:
        # DHT22s return errors occasionally, this is normal
        print(f"Read failed: {e.args[0]}")

    time.sleep(2)

Save as dht.py and run:

python3 dht.py

You should see the temperature and humidity printing every 2 seconds.

Why use a library instead of bitbanging the protocol

The DHT22 uses a custom one-wire protocol (not the same as the DS18B20's one-wire). The timing requirements are tight (microsecond-level). The Adafruit library handles the timing correctly. Writing your own is a debugging exercise you do not need.

The "Read failed" error

The DHT22 returns errors occasionally, especially on the Pi where Linux is not a real-time OS. The library raises RuntimeError and you should ignore it (or log it). If every read fails, the wiring is wrong.

Reading from cron

For a project that logs readings every 5 minutes, use cron:

crontab -e

Add:

*/5 * * * * /usr/bin/python3 /home/brian/dht.py >> /home/brian/dht.log 2>&1

This runs the script every 5 minutes and appends the output to a log file.

Writing the data to a database

For longer-term storage, SQLite is built in:

import sqlite3
import time
import board
import adafruit_dht

db = sqlite3.connect('/home/brian/sensors.db')
db.execute('''CREATE TABLE IF NOT EXISTS readings (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  temperature REAL,
  humidity REAL
)''')

dht = adafruit_dht.DHT22(board.D4)

while True:
    try:
        temperature = dht.temperature
        humidity = dht.humidity
        db.execute(
            'INSERT INTO readings (temperature, humidity) VALUES (?, ?)',
            (temperature, humidity)
        )
        db.commit()
        print(f"Saved: {temperature:.1f} C, {humidity:.1f} %")
    except RuntimeError as e:
        print(f"Read failed: {e.args[0]}")

    time.sleep(60)   # once a minute

Run with python3 sensor_logger.py (or via systemd for auto-restart, see below). After a day, query the data:

sqlite3 /home/brian/sensors.db "SELECT datetime(ts), temperature, humidity FROM readings ORDER BY ts DESC LIMIT 10"

Running it as a systemd service

Create /etc/systemd/system/dht-logger.service:

[Unit]
Description=DHT22 logger
After=network.target

[Service]
ExecStart=/usr/bin/python3 /home/brian/sensor_logger.py
WorkingDirectory=/home/brian
User=brian
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable dht-logger.service
sudo systemctl start dht-logger.service
sudo systemctl status dht-logger.service

Now the logger starts on boot, restarts if it crashes, and logs to syslog. This is the right way to run a long-lived sensor logger on a Pi.

When the Pi's GPIO library does not work

If import board or import adafruit_dht fails, the library is not installed. Re-run the pip3 install command.

If you are using a fresh Raspberry Pi OS image, you might also need:

sudo apt install -y python3-dev

For very old Pi OS versions, the Adafruit library was the old "Adafruit DHT" Python library, not the CircuitPython version. The new version is better; upgrade.

What to build next

  • A weather dashboard with a small OLED display.
  • A MQTT publisher (combine with the ESP32 MQTT tutorial on the subscribe side).
  • A multi-sensor logger (DHT22 + DS18B20 + light sensor).

The MQTT publisher is in the book IoT with Raspberry Pi. The multi-sensor version is in the book Raspberry Pi Sensors.


Chapter 03

Raspberry Pi: install and configure Mosquitto MQTT broker

raspberry-pi · 30 min

A local MQTT broker is the central nervous system of most home automation projects. Every sensor publishes to it. Every automation subscribes from it. Every dashboard reads from it. The broker is the part you want running on hardware you control, not on a cloud that might disappear.

This tutorial installs Mosquitto on a Raspberry Pi and configures it for a home network.

What you need

  • Raspberry Pi (any model) running Raspberry Pi OS
  • Network access to the Pi (you already know how to SSH in)

Install Mosquitto

sudo apt update
sudo apt install -y mosquitto mosquitto-clients

That's it. The default configuration starts the broker on port 1883 with no authentication.

Verify it works

On the Pi itself:

mosquitto_sub -h localhost -t "test/#" -v &
mosquitto_pub -h localhost -t "test/hello" -m "world"

You should see test/hello world printed in the subscriber.

Or from your laptop:

mosquitto_sub -h pi-pihole.local -t "test/#" -v

Then on the Pi:

mosquitto_pub -h localhost -t "test/hello" -m "world"

The laptop should see the message. If not, check the firewall on the Pi:

sudo ufw allow 1883/tcp

(Or, if you use iptables, allow port 1883 there.)

Configuring for a home network

The default config lets any device on the network publish or subscribe to any topic. That is fine for a closed home network, but I add two things:

  1. A username and password (so random devices on the network cannot impersonate your sensors).
  2. Allow anonymous read-only access (so a dashboard can subscribe without a password, but only the broker admin can publish).

Edit /etc/mosquitto/mosquitto.conf:

listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd

# Optional: persistent sessions (broker remembers subscriptions across restarts)
persistence true
persistence_location /var/lib/mosquitto/

# Optional: log to file
log_dest file /var/log/mosquitto/mosquitto.log
log_type error
log_type warning
log_type notice
log_type information

Create the password file:

sudo mosquitto_passwd -c /etc/mosquitto/passwd brian

(Enter a password when prompted.)

Restart:

sudo systemctl restart mosquitto

Test:

mosquitto_pub -h localhost -u brian -P your-password -t "test/hello" -m "world"
mosquitto_sub -h localhost -u brian -P your-password -t "test/#" -v

Letting devices connect without passwords (read-only)

If you have devices that only need to subscribe (e.g. a dashboard), you can allow anonymous read-only access. The trick is Mosquitto's ACL system.

Create /etc/mosquitto/acl:

# Default: only authenticated users
user brian
topic readwrite #

# Anonymous: read-only
user anonymous
topic read #

Update the mosquitto config:

listener 1883
allow_anonymous true
acl_file /etc/mosquitto/acl
password_file /etc/mosquitto/passwd

Restart:

sudo systemctl restart mosquitto

Now an anonymous subscriber (no username, no password) can read but cannot publish. The authenticated user brian can publish and subscribe.

For ESP32 clients, set the username and password in the PubSubClient library:

client.connect("esp32-publisher", "brian", "your-password");

Securing with TLS

If you want MQTT over TLS (so devices on the open internet can connect without sending the password in cleartext), you need a domain name and a certificate (e.g. from Let's Encrypt). The configuration is more involved; I cover it in the book Production MQTT.

For a home network, plain MQTT on port 1883 is fine.

Testing from Python

import paho.mqtt.client as mqtt
import time

def on_connect(client, userdata, flags, rc):
    print("Connected with result code " + str(rc))
    client.subscribe("ctrlaltbrian/#")

def on_message(client, userdata, msg):
    print(msg.topic + " " + str(msg.payload.decode()))

client = mqtt.Client()
client.username_pw_set("brian", "your-password")
client.on_connect = on_connect
client.on_message = on_message

client.connect("localhost", 1883, 60)
client.loop_start()

while True:
    client.publish("ctrlaltbrian/test", "hello from python")
    time.sleep(5)

Install paho-mqtt:

pip3 install --break-system-packages paho-mqtt

Run it. You should see the messages coming back to yourself.

Web-based monitoring

If you want a browser-based view of what is going through the broker:

sudo apt install -y mosquitto mosquitto-clients
sudo apt install -y mqtt-explorer   # no, this is a desktop app

For a server-side option, install MQTT Explorer via snap, or use a small web dashboard. The book Home Automation with Raspberry Pi uses Node-RED with the MQTT nodes for this.

Using Mosquitto with Home Assistant

If you are running Home Assistant, configure the MQTT integration to point at the local broker. HA will subscribe to homeassistant/# and you can configure devices to publish sensor data with the HA discovery prefix.

The HA version is in the book Home Automation with Raspberry Pi.

When the broker silently drops messages

  • Topic ACL: if the topic is not allowed for the user, the broker silently drops it. Check /etc/mosquitto/acl.
  • QoS 0: at most once. If the subscriber is offline, it misses the message. Use QoS 1 or 2 if you need guaranteed delivery.
  • Retained flag: new subscribers do not see the last message by default. Set the retain flag on publishes you want new subscribers to see immediately.

What to build next

  • A Node-RED dashboard that subscribes to your sensors.
  • Home Assistant with the MQTT integration.
  • A Telegram bot that sends you a message when a sensor trips.

The Node-RED dashboard is in the book Home Automation with Raspberry Pi. The Telegram bot is one of the next tutorials on this site.


Chapter 04

Raspberry Pi: run Node-RED for visual automations

raspberry-pi · 30 min

Node-RED is the visual automation tool I default to for home automation projects. You drag nodes onto a canvas, wire them together, and you have an automation. No code editor, no YAML, no fighting with config files.

This tutorial installs Node-RED on a Raspberry Pi and builds a small example: a temperature sensor publishes via MQTT, Node-RED reads it, and Node-RED sends you a Telegram message when the temperature goes above 27 C.

What you need

  • Raspberry Pi running Raspberry Pi OS
  • Mosquitto MQTT broker already running (covered in the previous tutorial)

Install Node-RED

The recommended way on Raspberry Pi OS Bookworm:

bash <(curl -sL https://raw.githubusercontent.com/node-red/linux-installers/master/deb/update-nodejs-and-nodered)

This script installs Node.js (if not present), Node-RED, and sets up the systemd service. It takes about 5-10 minutes.

Once installed, enable and start the service:

sudo systemctl enable nodered.service
sudo systemctl start nodered.service

Node-RED listens on port 1880 by default.

Access the editor

Open a browser on any computer on the same network:

http://pi-pihole.local:1880

You should see the Node-RED editor. It is a canvas with a palette of nodes on the left.

The flow: temperature alert via Telegram

Drag these nodes onto the canvas:

  1. mqtt in (under "input")
  2. function (under "function")
  3. telegram sender (under "telegram", you may need to install it first)

If telegram sender is not in the palette, install it:

  1. Menu (top right) >> Manage palette >> Install
  2. Search node-red-contrib-telegram
  3. Install

Configure the MQTT node

Double-click the mqtt in node:

  • Server: localhost:1883
  • Topic: ctrlaltbrian/sensor/temperature
  • QoS: 0
  • Name: Sensor in

Click Done.

Configure the function node

Double-click the function node. Paste this in the Function editor:

const temp = parseFloat(msg.payload);
if (temp > 27) {
    msg.payload = `Temperature is ${temp.toFixed(1)} C, turning on the AC.`;
    return msg;
}
return null;

This filters for hot temperatures only. return null discards messages that do not match.

Click Done.

Configure the Telegram node

You need a Telegram bot first. Talk to @BotFather on Telegram:

  1. Send /newbot
  2. Choose a name (e.g. "Pi Alerts")
  3. Choose a username (e.g. pi_alerts_bot)
  4. BotFather gives you a token. Save it.

Get your chat ID. Send a message to your bot, then visit:

https://api.telegram.org/bot<TOKEN>/getUpdates

Look for the chat.id field. That's your chat ID.

Double-click the telegram sender node:

  • Bot: configure a new bot with the token from above
  • Chat ID: your chat ID
  • Name: Send alert

Click Done.

Wire it up

Drag from the right edge of mqtt in to the left edge of function. Then from function to telegram sender.

Click Deploy (top right).

Test it

Publish a hot temperature to MQTT:

mosquitto_pub -h localhost -u brian -P your-password -t "ctrlaltbrian/sensor/temperature" -m "29.4"

You should see a Telegram message arrive.

Building a dashboard

Node-RED has a built-in dashboard via node-red-dashboard:

  1. Menu >> Manage palette >> Install >> node-red-dashboard
  2. Drag a gauge node from the dashboard palette onto the canvas
  3. Configure it to read from the same MQTT topic
  4. Wire the mqtt in to the gauge
  5. Deploy

Visit http://pi-pihole.local:1880/ui to see the dashboard.

You now have a real-time temperature gauge.

Common Node-RED nodes I use

  • mqtt in/out: read and write to MQTT topics
  • function: small JavaScript snippet for filtering or transforming
  • change: rename, set, delete, move fields in a message
  • switch: route messages to different paths based on conditions
  • debug: print the message to the debug sidebar
  • inject: send a test message on a button click or schedule
  • http in/out: HTTP endpoints (e.g. to receive a webhook)
  • exec: run a shell command
  • telegram sender: send a Telegram message
  • email: send an email

With these, you can build:

  • "If motion sensor trips between 11pm and 6am, send me a Telegram message."
  • "If the temperature is below 5 C, send me a warning that the pipes might freeze."
  • "If the front door opens, log the time and turn on the entryway light."

All without writing any Python, Bash, or other glue code.

When Node-RED does not load

  • Port 1880 already in use. Another service is using that port. Edit /lib/systemd/system/nodered.service and change the port, then restart.
  • Memory limit. Node-RED uses a lot of RAM for the editor. On a Pi Zero, the editor will be slow. The Pi 4 with 4 GB is comfortable.
  • Permission errors. Node-RED must be run as the pi user, not root. The install script sets this up; if you changed it, revert.

When to use Node-RED vs. Home Assistant vs. custom Python

  • Node-RED: visual, fast to prototype, great for "if X then Y" logic. Not a full home automation system.
  • Home Assistant: full home automation system with device integrations, automations, energy monitoring. Heavier, more features, more config.
  • Custom Python: maximum flexibility, harder to maintain, great for custom data pipelines.

I run Home Assistant for the main home automation, Node-RED for the visual automations that connect to it, and Python scripts for the data pipeline (e.g. logging to a database). All three on the same Pi.

What to build next

  • A multi-sensor dashboard with gauges for temperature, humidity, and pressure.
  • A presence detection system (when my phone joins the Wi-Fi, mark me as home).
  • A weather-based automation (close the blinds if it's sunny and the indoor temp is above 24).

The dashboard tutorial is in the book Home Automation with Raspberry Pi. The presence detection version is one of the next tutorials on this site.


Chapter 05

Raspberry Pi: serve files on your network with Samba

raspberry-pi · 30 min

A Raspberry Pi with a USB hard drive attached and Samba running is the smallest useful NAS. It will not compete with a Synology, but for a few hundred GB of family photos and a place for backups to land, it is perfect.

This tutorial walks through installing Samba, configuring a shared folder, and accessing it from Windows, macOS, and Linux.

What you need

  • Raspberry Pi (4 or 5 recommended; the older ones work but are slow for large file transfers)
  • USB hard drive or SSD (an SSD is faster; a USB 3 enclosure is what you want)
  • Powered USB hub if the drive needs more than 1.2 A

Step 1: mount the drive

Plug in the drive. Find its device name:

lsblk

You will see something like:

sda      8:0    0   1.8T  0 disk
└─sda1   8:1    0   1.8T  0 part

/dev/sda1 is the partition. Format it as ext4 (Linux-native; best performance) or NTFS (if you need Windows compatibility):

sudo mkfs.ext4 -L "storage" /dev/sda1

Mount it:

sudo mkdir -p /mnt/storage
sudo mount /dev/sda1 /mnt/storage
sudo chown -R brian:brian /mnt/storage

Make the mount persistent across reboots. Get the drive's UUID:

sudo blkid /dev/sda1

Note the UUID="..." value. Add to /etc/fstab:

UUID=your-uuid-here  /mnt/storage  ext4  defaults,noatime  0  2

Test:

sudo umount /mnt/storage
sudo mount -a

If the mount comes back, the fstab entry is right.

Step 2: install Samba

sudo apt install -y samba samba-common-bin

The default config starts Samba as a service. We will overwrite the config in a moment.

Step 3: configure the share

Back up the default config:

sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.bak

Edit /etc/samba/smb.conf. Replace the whole thing with:

[global]
   workgroup = WORKGROUP
   server string = Pi NAS
   security = user
   map to guest = bad user
   dns proxy = no

[storage]
   path = /mnt/storage
   browseable = yes
   writable = yes
   read only = no
   guest ok = no
   create mask = 0644
   directory mask = 0755
   valid users = brian

workgroup = WORKGROUP is the default Windows workgroup. Change if yours is different.

Step 4: add your user to Samba

Samba has its own password database (separate from the system password):

sudo smbpasswd -a brian

Enter a password. This is the password you will use when connecting from other computers.

Step 5: restart Samba

sudo systemctl restart smbd

Step 6: connect from another computer

Windows:

  1. Open File Explorer
  2. Type \\pi-pihole.local\storage in the address bar
  3. Enter username brian and the Samba password
  4. (Optional) Right-click the share and "Map network drive" for permanent access

macOS:

  1. Finder >> Go >> Connect to Server
  2. Enter smb://pi-pihole.local/storage
  3. Enter username and password
  4. The share appears in Finder under Locations

Linux:

sudo apt install -y cifs-utils
sudo mount -t cifs //pi-pihole.local/storage /mnt/remote -o username=brian,password=your-samba-password

Or use the GNOME/KDE file manager's "Connect to Server" option.

Performance

A Raspberry Pi 4 with a USB 3 SSD can do about 100 MB/s read and 50 MB/s write over gigabit ethernet. A USB 3 HDD is bottlenecked at about 100 MB/s by the drive itself. A USB 2 drive tops out at about 30 MB/s.

For most home uses (file backups, photo storage), this is more than enough. If you need 1 GB/s, you need a real NAS.

Accessing from outside the home network

For most people, do not. Exposing Samba to the internet is a bad idea. If you need remote access, set up a VPN (WireGuard is the easy pick) or use Tailscale for a peer-to-peer VPN with no port forwarding.

The WireGuard setup is in the book Self-Hosted with Raspberry Pi.

Backing up the share

The whole point of a NAS is having backups. Two layers:

Local backup (rsync to a second drive):

rsync -av /mnt/storage/ /mnt/storage-backup/

Offsite backup (rsync to a cloud storage provider):

rclone sync /mnt/storage/ remote:my-bucket/backup/

rclone supports Google Drive, Dropbox, Backblaze B2, S3, and many more.

When the share is slow

  • Network bottleneck. If you are on Wi-Fi, switch to ethernet. Large file transfers over Wi-Fi are 5-10x slower than ethernet.
  • Drive bottleneck. A USB 2 drive tops out at about 30 MB/s. Check with hdparm -t /dev/sda1.
  • Pi bottleneck. The Pi 4 has USB 3. The Pi 3 has USB 2 (max 30 MB/s). An SSD on USB 3 is much faster.

What to build next

  • A Plex media server (uses the same share).
  • A photo backup script that runs when your phone joins the Wi-Fi.
  • An automatic rsync to a cloud backup service.

The Plex server is in the book Self-Hosted with Raspberry Pi. The photo backup is one of the next tutorials on this site.


Chapter 06

Raspberry Pi: run Pi-hole to block ads network-wide

raspberry-pi · 30 min

Pi-hole is the project I run on every Pi I own. It is a DNS server that blocks ads, trackers, and malware domains. Every device on your network benefits automatically: phones, tablets, smart TVs, game consoles, even that annoying smart fridge.

You do not install anything on the devices. You just point your router at the Pi and Pi-hole blocks the bad stuff before it ever reaches your devices.

This tutorial covers the install and the one configuration change that makes it work.

What you need

  • Raspberry Pi (any model; the Zero 2 W is fine for a small network)
  • Network access to the Pi

Step 1: set a static IP

Pi-hole works best when the Pi has a static IP. If you set this up in the imager (covered in the headless setup tutorial), you are done. If not, edit /etc/dhcpcd.conf:

interface wlan0
static ip_address=192.168.1.100/24
static routers=192.168.1.1
static domain_name_servers=1.1.1.1 8.8.8.8

(Replace with your interface and network. Use eth0 if you are on ethernet.)

Reboot:

sudo reboot

Step 2: install Pi-hole

The one-liner:

curl -sSL https://install.pi-hole.net | sudo bash

This runs an interactive installer. The defaults are sane. Things to change:

  • Upstream DNS provider: Cloudflare (1.1.1.1) or Quad9 (9.9.9.9) are privacy-friendly picks.
  • Blocklists: the default list is fine. You can add more later.
  • Web admin interface: yes.
  • Lighttpd web server: yes (needed for the admin UI).
  • Logging: yes, but consider turning this off for max performance.

The installer tells you the admin password at the end. Save it.

Step 3: access the admin UI

Open a browser:

http://pi-pihole.local/admin

Or by IP:

http://192.168.1.100/admin

You should see the Pi-hole dashboard: queries today, percent blocked, top blocked domains, etc.

Step 4: point your network at Pi-hole

There are two ways:

Option A: change the router's DNS (recommended)

Log into your router (usually 192.168.1.1). Find the DNS settings. Change the primary DNS to 192.168.1.100 (your Pi's static IP). Leave the secondary DNS blank, or set it to a public DNS as a fallback (e.g. 1.1.1.1).

This routes every device on your network through Pi-hole automatically. The only exception is devices that hardcode their DNS (e.g. some smart TVs), but most respect the router's DNS.

Option B: change individual devices

If your router does not let you change DNS, or you only want to block ads on some devices:

  • macOS: System Settings >> Network >> [interface] >> Details >> DNS
  • Windows: Settings >> Network & internet >> [interface] >> DNS server assignment
  • iOS: Settings >> Wi-Fi >> [network] >> Configure DNS >> Manual
  • Android: Settings >> Network >> [network] >> Advanced >> Private DNS

Set the primary DNS to 192.168.1.100.

Option A is the right call for most people. Set it once and forget it.

Step 5: verify it works

From any device on the network, visit:

https://pi-hole.net

You should see the Pi-hole homepage. Now visit:

http://exampleadsdomain.com

If Pi-hole is blocking correctly, you should get a "blocked" page (or the browser will fail to load, depending on configuration).

Check the admin dashboard: you should see "queries today" going up, and "ads blocked" going up.

Adding more blocklists

The default blocklist blocks about 130,000 domains. You can add more. Some good community lists:

  • https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
  • https://mirror.accum.se/mirror/v.firebog.net/hosts/AdguardDNS.txt
  • https://raw.githubusercontent.com/anudeepND/blacklist/master/adservers.txt
  • https://raw.githubusercontent.com/pi-hole/pi-hole/master/adlists.list

To add a list:

  1. Admin UI >> Group Management >> Adlists
  2. Paste the URL
  3. Click "Add"
  4. Tools >> Update Gravity >> Update

You can also disable a list temporarily without removing it (useful for figuring out which list is breaking a website you care about).

Whitelisting domains that get blocked

The most common whitelist entries:

  • Google ads (for sites that detect adblockers and refuse to show content)
  • Affiliate links
  • Your own domains (don't block yourself)

To whitelist:

  1. Admin UI >> Whitelist
  2. Add the domain
  3. Click "Add"

Or via CLI:

pihole -w example.com

When a website breaks

The most common cause: a list is blocking a domain the website needs. Two paths:

  1. Identify which list is the culprit: disable each list one at a time and retest the website.
  2. Whitelist the domain temporarily.

For the rare case where the website is unusable without ads (e.g. YouTube with anti-adblock), whitelist or use the browser's built-in ad blocker for that site.

When Pi-hole breaks the internet entirely

You will see this if Pi-hole goes offline. Devices that use Pi-hole as their DNS will fail to resolve any domain. Fix:

  1. SSH into the Pi. Check pihole status.
  2. If Pi-hole is down but the Pi is up: pihole restartdns.
  3. If the Pi is down: reboot it.

To prevent total internet loss if Pi-hole dies, set the router's secondary DNS to a public server (e.g. 1.1.1.1). Devices will fall back to that if Pi-hole is unreachable.

Performance

Pi-hole on a Pi Zero handles about 100,000 queries per day without breaking a sweat. The Pi 4 handles 10x that. For a home network, even the Pi Zero is overkill.

The DNS query response time is usually under 5 ms. Most of that is the network round-trip, not Pi-hole.

What to build next

  • Pi-hole with Unbound (recursive DNS, no upstream).
  • Pi-hole with DoH/DoT (encrypted DNS queries).
  • A second Pi-hole on a Pi for redundancy.

The Unbound version is in the book Self-Hosted with Raspberry Pi. The redundancy setup is one of the next tutorials on this site.


Chapter 07

Raspberry Pi: use the Camera Module v3 with libcamera and Picamera2

raspberry-pi · 30 min

The Raspberry Pi Camera Module v3 is a 12-megapixel camera that costs about $35, plugs into the Pi's CSI port, and runs on every Pi from the Zero 2 W to the Pi 5. It is the camera I reach for when I need "a real camera" for a project, not a USB webcam.

The software side changed a few years ago. The old raspistill and raspivid commands are deprecated. The new system is libcamera, with command-line tools (libcamera-still, libcamera-vid) and a Python library called picamera2. This tutorial covers the modern stack.

What you need

  • Raspberry Pi 4 or Pi 5 (Pi 3 works but is slow; Pi Zero 2 W works but is slow for video)
  • Camera Module v3 (the v2 still works with this tutorial, the v3 has autofocus)
  • The ribbon cable that ships with the camera
  • Raspberry Pi OS Bookworm (64-bit) with desktop, or Lite plus libcamera-apps and python3-picamera2

The legacy raspistill vs current libcamera

The old way was the raspistill and raspivid commands, which used Broadcom's proprietary GPU code. The new way is libcamera, an open-source stack that works on more hardware and is not tied to the Pi.

If you see a tutorial from before 2022, it will say raspistill. That command is gone. The replacement is libcamera-still with mostly the same flags.

# Old
raspistill -o image.jpg

# New
libcamera-still -o image.jpg

The flag names are similar. The image quality is similar. The internal plumbing is completely different.

Install libcamera

On a fresh Raspberry Pi OS Bookworm install, libcamera-apps is already installed. If not:

sudo apt update
sudo apt install -y libcamera-apps python3-picamera2

Verify:

libcamera-hello

You should see a preview window for 5 seconds (on a desktop install) or a confirmation message (on a Lite install).

The camera interface is enabled by default on recent Raspberry Pi OS. If libcamera-hello says "no cameras found," enable the interface with sudo raspi-config >> Interface Options >> Camera >> Yes, then reboot.

Wiring: the ribbon cable

The Camera Module has a ribbon cable that plugs into the CSI port on the Pi. The port is between the HDMI ports and the GPIO header on a Pi 4, and near the GPIO header on a Pi 5.

The cable is fragile. The two things that go wrong:

  1. The cable is upside down. The blue side of the cable should face the Ethernet ports (Pi 4) or the board (Pi 5). If it is facing the other way, the camera is not detected.
  2. The cable is not fully inserted. The connector is a friction lock. Push the cable in until the lock clicks. If you have to force it, the cable is in the wrong orientation.

The "no camera detected" error is almost always one of those two things. Verify with:

libcamera-hello --list-cameras

If the camera is detected, the output shows the sensor model and capabilities.

Still capture

libcamera-still -o image.jpg

This captures a single still image. Default resolution is the camera's maximum (12 MP for v3, 8 MP for v2). The image saves to the specified file.

Useful flags:

  • -o image.jpg output file
  • --width 1920 --height 1080 specific resolution
  • --rotation 180 rotate (mounting orientation)
  • --quality 90 JPEG quality (0-100)
  • --awb auto auto white balance
  • --exposure sport exposure mode (long, normal, short, sport)

For timelapse:

libcamera-still -o frame_%04d.jpg --timelapse 5000 --timeout 60000

Captures one frame every 5 seconds for 60 seconds.

Video capture

libcamera-vid -o video.h264

This captures 1080p30 video in H.264 format. H.264 is the right format if you are going to play it back or upload it; the file size is reasonable.

Useful flags:

  • -o video.h264 output file
  • --width 1920 --height 1080 resolution
  • --framerate 30 frames per second
  • --bitrate 8000000 8 Mbps (default 17 Mbps for 1080p)
  • --timeout 10000 stop after 10 seconds

To convert H.264 to MP4 (most players want the container):

ffmpeg -i video.h264 -c copy video.mp4

The Picamera2 Python library

picamera2 is the Python library that replaces the old picamera. The old library does not work on Bookworm and the new Pi models. The new library does.

from picamera2 import Picamera2

picam2 = Picamera2()
config = picam2.create_still_configuration()
picam2.configure(config)
picam2.start()

# Let auto-exposure settle
import time
time.sleep(2)

picam2.capture_file("image.jpg")

The time.sleep(2) is the part most people skip. The camera's auto-exposure needs a couple of seconds to settle. Without it, the first frame is under- or over-exposed.

For a preview loop:

import time
from picamera2 import Picamera2

picam2 = Picamera2()
config = picam2.create_preview_configuration()
picam2.configure(config)
picam2.start()

while True:
    frame = picam2.capture_array()
    # frame is a numpy array, shape (height, width, 3)
    # process or display it
    time.sleep(0.1)

The capture_array method returns the image as a numpy array. This is the right method if you are doing image processing (OpenCV, Pillow, scikit-image).

The autofocus trick (v3 only)

The Camera Module v3 has autofocus. The v2 has a fixed focus lens. The v3's autofocus is controlled from Picamera2:

from picamera2 import Picamera2
import time

picam2 = Picamera2()
config = picam2.create_still_configuration()
picam2.configure(config)
picam2.start()

# Continuous autofocus
picam2.set_controls({"AfMode": 2})   # 2 = continuous, 1 = manual, 0 = off

time.sleep(5)  # let it focus
picam2.capture_file("focused.jpg")

The autofocus is slow (about 1-2 seconds to lock). For a project that needs fast capture, set the focus manually:

# Manual focus at a specific lens position (0.0 = far, 10.0 = close)
picam2.set_controls({"AfMode": 0, "LensPosition": 5.0})

The v3 also has a wider field of view than the v2 (about 66 degrees horizontal vs 62), and it supports HDR. For most projects, the autofocus alone is the reason to pick the v3.

Exposure modes and AWB

The camera has two auto-adjustment systems running in parallel:

  • Exposure (the brightness of the image). The camera picks a shutter speed and ISO to make the image well-exposed.
  • AWB (auto white balance). The camera picks color gains so that white things look white under the current lighting.

Both are "auto" by default. You can override:

picam2.set_controls({
    "ExposureTime": 10000,        # 10 ms in microseconds
    "AnalogueGain": 1.0,          # ISO 100 equivalent
    "ColourGains": (1.5, 1.2),   # red gain, blue gain
})

The values are tricky to pick by hand. The rule: leave the camera in auto mode, override only if the auto is consistently wrong (a window scene with a bright sky, a lab with fluorescent lights, etc.).

Streaming over RTSP

For "show the camera feed to multiple viewers," RTSP is the standard protocol. There is a libcamera-vid mode for it:

libcamera-vid -t 0 --inline -o - | cvlc stream:///dev/stdin --sout '#rtp{sdp=rtsp://:8554/stream}' :demux=h264

This is more involved than I want to put in a beginner tutorial. The short version: VLC, GStreamer, or mediaMTX (formerly rtsp-simple- server) can all take the H.264 stream from libcamera-vid and expose it as RTSP. The viewers use VLC or a web player.

For a simpler "view the camera in a browser," use rpicam-vid (the newer name for libcamera-vid) with the --inline and HTTP serving. Or skip the streaming and just push JPEGs to a web server with a Python script.

ESP32-CAM vs Pi Camera

The ESP32-CAM is a $10 board with an OV2640 camera. It runs over Wi-Fi, draws very little power, and is the right pick for a battery- powered, always-on camera.

The Pi Camera wins on:

  • Image quality. The v3 is 12 MP, the OV2640 is 2 MP.
  • Autofocus. The v3 has it, the OV2640 does not.
  • Processing. The Pi can do real-time image processing (face detection, object recognition). The ESP32-CAM struggles.
  • Flexibility. The Pi runs full Linux, you can install any library.

The ESP32-CAM wins on:

  • Cost. About $10 vs $35 for the camera, plus the Pi.
  • Power. 200 mA vs 600-1000 mA for a Pi.
  • Integration. The ESP32-CAM has the camera on the same board as the microcontroller.

Rule of thumb: if the camera is the main thing and the project runs on battery, ESP32-CAM. If the camera is one of several things and the Pi is already there, Pi Camera.

What you learned

  • The modern camera stack on the Pi is libcamera (CLI tools) and picamera2 (Python).
  • The Camera Module v3 has autofocus; the v2 has a fixed-focus lens.
  • libcamera-still for photos, libcamera-vid for video, picamera2 for Python.
  • The "no camera detected" error is almost always a ribbon cable orientation problem.

When something breaks

libcamera-hello says "no cameras available". The ribbon cable is upside down or not fully inserted. Power off, reseat the cable.

The preview is green or pink. The cable is not fully inserted or the cable is damaged. Try a different cable.

The autofocus hunts forever. The lens is too close to the subject or the lighting is too low. Use manual focus with LensPosition.

ModuleNotFoundError: No module named 'picamera2'. The package is not installed. Run sudo apt install -y python3-picamera2.

The image is upside down. The camera is mounted upside down. Add picam2.set_controls({"Rotation": 180}) or use the --rotation 180 flag on the CLI tools.

The Python script crashes after a few minutes. The camera is running out of resources. Add picam2.close() at the end of the script, or use a context manager:

with Picamera2() as picam2:
    picam2.start()
    picam2.capture_file("image.jpg")

What to build next

  • A wildlife camera with motion detection.
  • A time-lapse rig for a plant growing.
  • A security camera with RTSP streaming.
  • A doorbell camera that sends a snapshot to your phone when pressed.

The time-lapse rig is the easiest first project. The RTSP security camera is the most useful.


Chapter 08

Raspberry Pi: GPIO with Python and gpiozero, the right way

raspberry-pi · 30 min

A Raspberry Pi is a Linux computer with a bunch of pins on the header that you can read and write from Python. That is the whole "physical computing" pitch. You wire an LED to a pin, you write a Python script, the LED turns on.

The trick is doing it without frying the Pi. GPIO pins run at 3.3V and have a current limit of about 16 mA each. A short circuit will not immediately stop working, but it will slowly cook the pin, and on a bad day, the SoC. I have killed a Pi this way. (I will tell you about it in the cautionary tale section.)

This tutorial uses gpiozero, which is the right library for almost every Pi GPIO project.

What you need

  • Raspberry Pi (any model with the 40-pin header, i.e. Pi 2 and newer)
  • An LED (any color)
  • A 220 ohm or 330 ohm resistor
  • A momentary pushbutton
  • A small buzzer (the active 3-pin type)
  • Three jumper wires

RPi.GPIO vs gpiozero: pick gpiozero

There are two main Python GPIO libraries. The older one, RPi.GPIO, is the one most tutorials reference. It works, but it requires you to set up pins, configure pull-up resistors, and clean up at exit. It feels like programming a microcontroller from the 1990s.

gpiozero is the modern one. It does the right thing by default: the LED knows it is an LED (so it handles on/off correctly), the button knows it is a button (so it handles pull-ups correctly). You write led.on() instead of led.write(1).

The rule: use gpiozero unless you have a specific reason to need RPi.GPIO (e.g. you are porting old code, or you need software PWM that gpiozero does not support). Most projects are simpler with gpiozero.

from gpiozero import LED, Button

led = LED(17)
button = Button(2)

button.when_pressed = led.on
button.when_released = led.off

That is a working button-controlled LED in 5 lines.

Install gpiozero

gpiozero is part of Raspberry Pi OS, so on a fresh install you already have it. If you are on a stripped-down image:

sudo apt install -y python3-gpiozero

That is it. No pip install (the apt version is the one to use; it matches the system's libgpiod).

The "hello world" of Pi GPIO: LED + button

Wire this:

Component Pin (header) GPIO
LED anode (long leg) Pin 11 GPIO 17
LED cathode (short leg) through 220R to GND Pin 6 (GND)
Button leg 1 Pin 6 (GND) GND
Button leg 2 Pin 3 GPIO 2
GPIO 17 ----[ LED ]----[ 220R ]---- GND
GPIO 2  ----[ BUTTON ]---- GND

Now the script:

from gpiozero import LED, Button
from signal import pause

led = LED(17)
button = Button(2, pull_up=True)

button.when_pressed = led.on
button.when_released = led.off

pause()

pause() blocks forever, but in a way that lets gpiozero's internal thread handle the button events. This is the right way to write a long-running Pi GPIO script.

Press the button: LED on. Release: LED off.

Pin numbering: gpiozero uses BCM numbering, not physical pin numbers. GPIO 17 is physical pin 11. The pinout at https://pinout.xyz is the canonical reference.

Internal pull-ups

A button has two states: pressed and not pressed. From the Pi's point of view, those should be two different voltages. Without help, the "not pressed" state is a floating input, which reads as random noise (sometimes high, sometimes low, occasionally flickering as you wave your hand near the wire).

A pull-up resistor ties the input to 3.3V by default, so "not pressed" reads as high (1), and "pressed" pulls it to GND (0). gpiozero turns on the internal pull-up by default with Button(pin, pull_up=True), which is what you want for a button wired between GPIO and GND.

If you wire the button between GPIO and 3.3V instead, use pull_up=False (or equivalently, pull_down=True).

The internal pull-ups are about 50k ohms. For long wires or noisy environments, add an external 10k pull-up. For typical breadboard projects, the internal one is enough.

The LED + buzzer + button combo

Adding a buzzer to the previous example. Wire the buzzer's signal pin to GPIO 27 (pin 13 on the header), and the other two to GND and 3.3V.

from gpiozero import LED, Button, Buzzer
from signal import pause

led = LED(17)
buzzer = Buzzer(27)
button = Button(2, pull_up=True)

def alert():
    led.on()
    buzzer.on()

def quiet():
    led.off()
    buzzer.off()

button.when_pressed = alert
button.when_released = quiet

pause()

Press the button: LED on, buzzer on. Release: both off. This is the pattern for a "doorbell" or "alarm" project.

The pin factory (Pi 5 vs Pi 4)

gpiozero has a "pin factory" abstraction so you can switch between the underlying GPIO library without changing your code. The default is lgpio on a Pi 5, and RPi.GPIO on a Pi 4. Most users never notice the difference.

If you are on a Pi 5 and your code raises ImportError: No module named 'RPi.GPIO', install the new pin factory:

sudo apt install -y python3-lgpio python3-rpi-lgpio

The Pi 5 also has a different pinout for the PoE header, but the main 40-pin GPIO header is the same as the Pi 4. Your code does not need to change.

The "I broke my Pi" cautionary tale

I shorted 3.3V to GND through a misplaced jumper wire while the Pi was running. The Pi did not shut off. It kept running. Two days later, I noticed one of the GPIO pins was not reading correctly. A week later, the entire Pi froze and never came back.

The lesson: a short circuit is not dramatic. It is a slow death. The Pi does not have the same protection as a microcontroller.

Things that protect you:

  • A 220 ohm resistor in series with every LED. Not optional. The LED will draw too much current otherwise.
  • A 1k resistor in series with inputs that could see more than 3.3V.
  • Never drive a GPIO output to a motor or relay coil directly. Use a transistor or a driver board. The GPIO can source 16 mA, the motor wants 200 mA, the GPIO dies.
  • Wire the circuit before powering the Pi. Easier to debug, easier to undo a mistake.

Things that will not protect you:

  • The Pi's polyfuse (only protects the 5V rail, not the GPIO).
  • Linux. Linux is not in the loop for GPIO current limits.

When to use the Pi vs a microcontroller

The Pi is overkill for blinking an LED. A Pico or ESP32 will do it for $4, with no OS, no SD card to corrupt, and instant boot.

The Pi is the right choice when:

  • You need a real OS (file system, network stack, video, audio).
  • You are running services (Home Assistant, MQTT broker, Pi-hole).
  • You need to talk to a peripheral that needs drivers (a camera, a USB device, an HDMI display).
  • You are already running a Pi and the GPIO is a small add-on.

The microcontroller is the right choice when:

  • The project is one sensor and one actuator.
  • Boot time matters.
  • You do not want a full Linux install to maintain.

Rule of thumb: if the project's "main thing" is the GPIO (a robot, a sensor node, a light controller), use a microcontroller. If the GPIO is an add-on to a service the Pi is already running, use the Pi's GPIO.

What you learned

  • gpiozero is the right library for almost every Pi GPIO project.
  • Pull-up resistors make button inputs stable.
  • Current limits matter: 16 mA per pin, 3.3V logic.
  • The Pi is overkill for blinking LEDs. A microcontroller is cheaper and more reliable for that.

When something breaks

RuntimeError: No access to /dev/gpiochip0. You forgot sudo, or the user is not in the gpio group. Add the user: sudo usermod -aG gpio $USER and log out and back in.

The button reads as "always pressed". Wiring issue. The button is between GPIO and 3.3V instead of GPIO and GND, so the pull-up is fighting the input. Swap the wiring, or change pull_up=True to pull_up=False.

The LED is very dim. The resistor is too big. 220 ohm is the standard; 1k or higher is for low-current indicator LEDs.

The Pi is unresponsive after wiring a motor. You drew too much current from a GPIO. The Pi is probably still alive, but that pin is dead. Use a transistor or a motor driver board next time.

ImportError: No module named 'gpiozero'. The package is not installed. Run sudo apt install -y python3-gpiozero.

What to build next

  • A traffic light (3 LEDs, timed sequence).
  • A doorbell (button + buzzer, no LEDs).
  • A simple motion alarm (PIR sensor + buzzer + LED).
  • A relay-controlled lamp (with a 5V relay module, not a bare relay).

The relay module project is the one most people want to do next. The traffic light is the one most fun to build first.


Chapter 09

Raspberry Pi: WireGuard VPN with PiVPN, your own private tunnel

raspberry-pi · 30 min

I needed to reach my home network from a coffee shop, a hotel, and a friend's house. I did not want to expose individual services to the internet. A VPN is the right tool: one entry point, encrypted, my phone becomes "on the home network" wherever I am.

WireGuard is the modern VPN. It is faster than OpenVPN, simpler to configure, and the code is small enough to audit. PiVPN is a shell script that automates the install on a Raspberry Pi. Together they take about 20 minutes from "fresh Pi" to "VPN works on my phone."

This tutorial covers the install, the public/private key pair, the phone config, the kill switch pattern, and the OpenVPN comparison.

What you need

  • Raspberry Pi 3, 4, or 5 running Raspberry Pi OS (Lite is fine)
  • The Pi on your home network with a known IP address (or a hostname via DDNS)
  • Your router's admin password (to forward one port)
  • An iPhone or Android phone
  • About 30 minutes

What a VPN does for a Pi

A VPN puts your phone "inside" your home network, even when the phone is on a coffee-shop Wi-Fi. Once connected:

  • You can reach any device on the home network (the Pi, your NAS, your printer, the Home Assistant instance).
  • All traffic between your phone and the Pi is encrypted.
  • The phone can reach the internet through the Pi if you configure it that way (full tunnel), or only reach the home network (split tunnel).

The VPN is the right tool when:

  • You want to access services at home without exposing them to the internet.
  • You do not trust the network you are on (hotel, coffee shop, airport).
  • You want all traffic from your phone to go through your home connection (full tunnel).

The VPN is the wrong tool when:

  • You just need to expose one service to the internet (use a reverse proxy with HTTPS, or a service like Tailscale).
  • You are on a corporate network that blocks VPNs.
  • You need to share the tunnel with multiple devices (Tailscale's "Tailnet" is the right tool for that).

Install WireGuard via PiVPN

PiVPN is a shell script that wraps the WireGuard install. It handles the key generation, the server config, and the user creation.

curl -L https://install.pivpn.io | bash

The script is interactive. It will ask:

  1. Static IP: confirm the Pi's IP or set one.
  2. User: which Linux user should own the configs (usually pi or the user you set up).
  3. WireGuard: yes.
  4. Port: default 51820, change if you have a port conflict.
  5. DNS: pick your home DNS provider (Pi-hole, AdGuard, Cloudflare, etc.).

It will also offer unattended upgrades, which I enable for the WireGuard package.

When the script finishes, the WireGuard service is running and the firewall is configured. Verify:

sudo pivpn status

You should see the WireGuard interface and a peer list (empty for now).

The public/private key pair

WireGuard uses public-key cryptography. Each peer (the Pi, your phone) has a key pair. The public key is shared, the private key is kept secret.

  • The Pi has a private key in /etc/wireguard/wg0.conf.
  • Your phone will have its own key pair, generated when you add it with pivpn add.

The keys are tied to the IP address inside the VPN. The Pi is usually 10.6.0.1, the phone is 10.6.0.2, and so on. The keys tell the server "this IP is allowed to talk to me."

The math is the boring part. The takeaway: do not share the private key. The public key is in the peer's config file, and that is the one you copy to the other side.

Add a peer (your phone)

sudo pivpn add

The script asks for a name (e.g. brian-phone) and an optional expiration. It generates a key pair, writes the config file, and prints a QR code.

Scan the QR code with the WireGuard app on your phone. The app imports the config. Tap the toggle. You are connected.

Verify from the Pi:

sudo pivpn status

You should see your phone's peer, with a "latest handshake" timestamp that is recent. If the timestamp is empty, the connection is not established.

The kill switch pattern

WireGuard has a "kill switch" option in the phone app: if the VPN drops, all internet traffic stops. This is the right setting for a hotel Wi-Fi, where you do not want the phone to fall back to the untrusted network.

In the WireGuard app, edit the tunnel, and enable "Always-On" and "Block connections without VPN." That is the kill switch.

The trade-off: with the kill switch on, if the VPN server is down, your phone has no internet. For most people, that is the right trade-off. For some people, it is too aggressive. Pick one.

Routing all traffic through the Pi (full tunnel)

By default, the WireGuard config on the phone only routes traffic to the home network through the VPN. Internet traffic goes directly out from the phone (split tunnel).

For full tunnel (all internet traffic goes through the Pi), edit the phone's config and add:

AllowedIPs = 0.0.0.0/0, ::/0

This routes everything through the VPN. The Pi then needs IP forwarding and NAT configured, which PiVPN does by default.

The full tunnel is the right setting when:

  • You do not trust the network you are on (untrusted Wi-Fi).
  • You want to appear to be at home (for streaming services with geo-restrictions).

The split tunnel is the right setting when:

  • You want fast internet (the Pi's upload speed is the bottleneck for full tunnel).
  • The home network is not a privacy sanctuary (you do not need to hide which network you are on).

For "I just want to reach my home stuff," split tunnel is enough.

The "WireGuard over Wi-Fi" reliability gotcha

WireGuard over Wi-Fi can be flaky if:

  • The Pi is on Wi-Fi and the connection drops occasionally. The VPN is up, but the underlying network is dropping packets.
  • The phone's Wi-Fi is congested (busy coffee shop). Same problem, different side.

The fix: if possible, wire the Pi to the router with ethernet. The phone's Wi-Fi is what it is, but the Pi's connection to the home network should be reliable.

The other gotcha: NAT. Most home networks have the Pi behind the router's NAT. The router needs a port forward for WireGuard (UDP 51820 by default) to the Pi. Without that port forward, the phone cannot reach the Pi from outside the home network.

Configure the port forward on the router: external UDP 51820 -> internal UDP 51820 at the Pi's IP. The Pi should have a static IP (or a DHCP reservation) so the port forward stays valid.

WireGuard vs OpenVPN

For almost every home use case, WireGuard wins:

  • Speed. WireGuard is 2-3x faster than OpenVPN at the same CPU cost.
  • Config. WireGuard config is one file, OpenVPN config is many files and a CA.
  • Code size. WireGuard is about 4,000 lines of code, OpenVPN is 100,000+. Smaller code is easier to audit.
  • Roaming. WireGuard handles network changes (Wi-Fi to cellular) without dropping the tunnel, OpenVPN has to reconnect.

OpenVPN wins in two places:

  • Corporate compatibility. Some corporate networks block UDP (which WireGuard requires) but allow TCP (which OpenVPN can do).
  • Mature tooling. OpenVPN has been around longer, has more documentation, more integrations, more "how-to" articles.

For a home VPN, WireGuard is the right choice in 2026.

The security audit: where are the keys?

After you have set this up, take a minute to think about where the keys are:

  • The Pi's private key: /etc/wireguard/wg0.conf on the Pi. Root access only.
  • The phone's private key: inside the WireGuard app on the phone, which is encrypted by the phone's filesystem encryption.
  • The public keys: in each side's config file, which is the same as the other side.

The keys are on your Pi and on your phone. They are not on a server. They are not in the cloud. There is no central authority that can be subpoenaed, hacked, or compromised. This is the part of the design that makes WireGuard trustworthy.

If the phone is lost, remove its peer from the Pi:

sudo pivpn remove brian-phone

If the Pi is compromised, the keys are exposed. Reinstall the Pi and generate new keys. The phone's config has to be updated.

What you learned

  • PiVPN automates the WireGuard install on a Raspberry Pi.
  • Each peer (the Pi, your phone) has a key pair. Public keys are shared, private keys are kept secret.
  • A kill switch blocks internet traffic if the VPN drops.
  • WireGuard beats OpenVPN on speed, config, and code size.

When something breaks

The phone cannot connect. Check the port forward on the router (UDP 51820 to the Pi). Check the Pi's public IP address (it can change if you have a dynamic IP from your ISP, use a DDNS service like DuckDNS or No-IP).

The phone connects but cannot reach anything. The IP forwarding or NAT is not set up. PiVPN sets this up by default; if you removed it, the server config is in /etc/wireguard/wg0.conf.

The connection drops every few minutes. The Pi is on Wi-Fi and the link is unstable. Move the Pi to ethernet.

pivpn status shows the peer but no handshake. The keys do not match. Re-add the peer and re-scan the QR code on the phone.

The kill switch is too aggressive. Disable "Always-On" or "Block connections without VPN" in the WireGuard app.

What to build next

  • Add a second peer (laptop, another phone).
  • Add split-tunnel DNS so the phone's DNS queries go through Pi-hole at home.
  • Set up DDNS so the Pi's IP can change without breaking the phone's config.
  • Move from PiVPN to Tailscale for zero-config multi-device.

The DDNS setup is the next thing most people need. Tailscale is the modern alternative if you do not want to forward a port.


Chapter 10

Raspberry Pi: install Home Assistant the right way, with backups and automations

raspberry-pi · 30 min

Home Assistant is the open-source home automation platform that ties together every smart device in your house: lights, sensors, switches, thermostats, vacuums, sprinklers, the garage door. It runs on a Raspberry Pi, integrates with everything from Philips Hue to MQTT to ESPHome, and has a real automation engine.

The install has three flavors (OS, Supervised, Container) and people argue about which is right. This tutorial picks one (OS), walks through the install, and covers the parts most people get wrong (the backup, the YAML automation, the device-discovery magic).

What you need

  • Raspberry Pi 4 (2 GB minimum, 4 GB recommended) or Pi 5
  • A microSD card (32 GB or larger, A1 rated) or, better, a USB SSD
  • An ethernet connection (Wi-Fi works on the Pi for setup, ethernet is more reliable long-term)
  • About 30 minutes for the install, plus 15 minutes for the initial wizard

Home Assistant OS vs Supervised vs Container

There are three install flavors:

  • Home Assistant OS is a minimal Linux distribution whose only job is to run Home Assistant. The supervisor manages add-ons, updates, and backups. This is the recommended path for a Pi.
  • Home Assistant Supervised is Home Assistant running on top of your own Debian install. You get a supervisor, but you also have to maintain the OS. It is the most fragile option.
  • Home Assistant Container is Home Assistant in a Docker container. You do not get a supervisor, add-ons, or the built-in backup tool. It is the right pick if you already have a Docker host and you know what you are doing.

For a Pi that is dedicated to home automation, Home Assistant OS is the right choice. It is the path with the least ongoing maintenance.

Install Home Assistant OS

The official installer is at https://www.home-assistant.io/installation/raspberrypi.

The flow:

  1. Download the Raspberry Pi Imager (the same one you use for Raspberry Pi OS).
  2. Choose OS >> Other specific-purpose OS >> Home assistants and home automation >> Home Assistant OS.
  3. Choose your storage (the SD card or USB SSD).
  4. Optionally configure Wi-Fi and SSH through the imager's "edit settings" menu. (Ethernet is simpler if you have it.)
  5. Write the image.

Boot the Pi. Wait about 20 minutes for the initial setup. The Pi will appear on your network as homeassistant.local.

From a browser on the same network, go to http://homeassistant.local:8123. The initial setup wizard starts.

The initial setup wizard

The wizard asks for:

  1. Username and password. This is the admin account. Pick a strong password; it is the front door to your house.
  2. Name of your home. Shown in the UI and used in automations (person.brian is one example).
  3. Location. Used for sunrise/sunset automations and weather.
  4. Privacy. Whether to share anonymous usage data with the Home Assistant project. I share.
  5. Devices found on the network. The wizard scans for compatible devices and offers to set them up. This is the device-discovery magic (more on this below).

When the wizard finishes, you are in the default "Overview" dashboard. It is mostly empty. The next step is adding integrations.

The integrations

An integration is a driver for a specific device or service. The popular ones:

  • MQTT for talking to ESP32 sensors, Node-RED, and other home-grown things. Requires a separate MQTT broker (Mosquitto is the standard pick; the add-on is a one-click install).
  • ESPHome for ESP32 devices flashed with ESPHome firmware. The integration auto-discovers them on the network.
  • Philips Hue for Hue lights and accessories. The integration finds the Hue bridge automatically.
  • TP-Link Kasa, Shelly, Z-Wave JS, Zigbee Home Automation, Google Cast, Apple TV, Sonos - the list is long. Most of them auto-discover on the local network.

To add an integration: Settings >> Devices & Services >> Add Integration >> search for the name. The flow is the same for all of them.

The MQTT and ESPHome integrations are the two I add first on every fresh install. The rest depend on the devices you have.

The automations editor (the GUI)

Settings >> Automations & Scenes >> Create Automation. The editor is a drag-and-drop flow:

  1. Trigger: what starts the automation. Time of day, device state, MQTT message, webhook call.
  2. Condition (optional): an extra check before running. "Only if someone is home," "only if it's dark outside."
  3. Action: what to do. Turn on a light, send a notification, call a service.

Example: turn on a light at sunset.

  • Trigger: Sun >> Sunset
  • Action: Light: Turn on light.living_room

That's it. The automation runs every day at sunset.

The GUI editor is the right tool for 80% of automations. For the other 20%, the YAML editor is more powerful.

The YAML automation (for complex rules)

The same automation in YAML:

alias: "Living room light at sunset"
trigger:
  - platform: sun
    event: sunset
    offset: "-00:30:00"   # 30 minutes before sunset
action:
  - service: light.turn_on
    target:
      entity_id: light.living_room
    data:
      brightness_pct: 80
      color_temp_kelvin: 2700

The YAML version lets you do things the GUI cannot, like offsets on the trigger, complex conditions, and parallel actions. The downside: a typo breaks the automation, and the error message is sometimes cryptic.

The rule: use the GUI for simple stuff, the YAML for complex stuff, and do not be afraid to switch back and forth.

The device-discovery magic (mDNS)

When you add an integration, Home Assistant scans your local network for compatible devices using mDNS (multicast DNS, also known as Bonjour). This is why your Hue bridge, your ESPHome devices, and your Sonos speakers show up automatically.

The trade-off: mDNS only works on the local network. If you access Home Assistant over a VPN or a remote proxy, device discovery does not work (the mDNS packets are not routed). The devices that were already added keep working; only new device discovery is affected.

If you have a complex network (multiple VLANs, multiple subnets), the mDNS reflector is the fix. It is a small service that forwards mDNS between subnets.

The "I bricked my install" recovery section

Home Assistant is a full OS, and full OS installs can break. Common breakages:

  • A bad update that crashes on boot.
  • A misconfigured YAML that prevents Home Assistant from starting.
  • An SD card that has had too many write cycles.

The fix is a backup. The good news: Home Assistant has a built-in backup tool.

Settings >> System >> Backups >> Create Backup. Pick what to include (the full Home Assistant config, the add-ons, the automation history). The backup saves to a local file or to a cloud provider (Google Drive, Dropbox, etc.).

The rule: take a backup before every update, and especially before any time you edit YAML or add a new integration. Restoring from a backup is much faster than rebuilding from scratch.

If the install is bricked, the recovery flow is:

  1. Remove the SD card or SSD from the Pi.
  2. Mount it on a laptop with a card reader.
  3. Find the backups directory.
  4. Copy the most recent backup to a safe place.
  5. Reflash the SD card with Home Assistant OS.
  6. Boot, run through the wizard.
  7. Restore the backup. Most of your config comes back, including integrations, automations, and add-ons.

The add-ons may need to be reinstalled if the OS version changed. The devices need to be reachable again (they were not stored in the backup, just their config in Home Assistant).

Home Assistant vs Node-RED

Node-RED is the other home automation tool people compare to Home Assistant. Both are real options.

Home Assistant wins on:

  • Out-of-the-box integrations. Hundreds of devices have native integrations, with auto-discovery and a UI.
  • The automation engine. The trigger/condition/action model is built in, no flow editor required.
  • The community. A huge ecosystem of add-ons, custom integrations, and forum answers.

Node-RED wins on:

  • Flexibility. The flow editor handles complex logic (loops, conditionals, parallel branches) more naturally than Home Assistant automations.
  • Language-agnostic processing. JavaScript, Python, and shell snippets can be embedded in a flow.
  • Integration with non-home-automation tools. Node-RED is a general-purpose flow engine; it integrates with databases, web APIs, and message brokers without add-ons.

The rule: Home Assistant is the right tool for "smart home automation." Node-RED is the right tool for "data flow automation" where the data happens to be in a smart home.

A lot of people run both. Home Assistant for the integrations and the UI, Node-RED for the complex automations, with Home Assistant calling Node-RED flows via the rest_command integration.

What you learned

  • Home Assistant OS is the recommended install path on a Pi.
  • Integrations connect Home Assistant to devices. Most are auto-discovered.
  • The GUI automations cover 80% of cases. The YAML automations cover the other 20%.
  • Backups before every update, and especially before any config edit.

When something breaks

Home Assistant will not boot. A bad YAML or a failed update. Boot from the SD card on a laptop, fix the YAML, or reflash and restore from backup.

The integrations page shows "failed to set up." The device is unreachable. Check the network (same subnet, no firewall blocking the mDNS port). Restart the integration.

The automation does not run. The trigger is wrong, or the condition is filtering it out. The Traces tab (in the automation detail view) shows the history and the reason each run did or did not fire.

The UI is slow. Too many devices, or a too-small Pi. Move to a Pi 4 with 4 GB or a Pi 5.

The SD card is corrupted. Replacements are cheap. Reflash and restore from backup. The lesson: backups.

What to build next

  • A "good morning" automation that turns on lights, starts the coffee maker (via a smart plug), and reads the weather.
  • A presence detection setup that knows who is home and adjusts the climate accordingly.
  • A dashboard for a wall-mounted tablet.
  • An alarm panel that arms when everyone leaves and disarms when someone arrives.

The presence detection setup is the most useful for a household. The wall-mounted dashboard is the most fun to build.


Chapter 11

Raspberry Pi: install Docker the right way and run multi-service stacks

raspberry-pi · 30 min

A Raspberry Pi running a few services with systemctl and a pile of apt install commands works. It also gets unwieldy fast. The next service you add conflicts with a Python version, the upgrade path becomes "rebuild the SD card from scratch," and you cannot tell which service owns which file.

Docker fixes this. Each service is in its own container with its own filesystem, its own dependencies, and its own lifecycle. You upgrade one without touching the others. The Pi becomes a small server, not a pile of hacks.

This tutorial covers the install, the arm64 trap, the multi-container pattern with docker-compose, and the RAM ceiling that catches people running a Pi 4 with too many services.

What you need

  • Raspberry Pi 4 (4 GB or 8 GB) or Pi 5 (4 GB or 8 GB) running Raspberry Pi OS Bookworm (64-bit)
  • A class 10 SD card or, ideally, a USB SSD (Docker pulls a lot of I/O)
  • About 30 minutes
  • A working internet connection

Why Docker on a Pi

The benefits are the same as Docker on a server:

  • Isolation. Each service has its own filesystem. A bad config in one does not corrupt the others.
  • Reproducibility. A docker-compose.yml is a complete spec for the service. Rebuild the Pi, copy the file, run docker compose up, and you are back where you were.
  • Upgradability. Upgrade a service by changing a tag in the compose file. Roll back by changing it back.
  • Disposable. A docker rm removes the service entirely, no leftover config files.

The costs are the same as Docker on a server:

  • Slight CPU overhead. Negligible on a Pi 4 or 5.
  • Disk space. Each image is a few hundred MB. Plan for at least 16 GB of free space.
  • Debugging. A bug inside a container is harder to debug than a bug on the host. The tooling helps, but it is one more layer.

For "I am running 3-4 services on a Pi," the trade is worth it.

Install Docker

The convenience script is the standard install path:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

The "convenience script" warning is real. It runs as root, it pulls from Docker's apt repo, and you are trusting that the script does what its README says. For a production server, I would review the script before running it. For a home Pi, the risk is "Docker Inc goes rogue," which is a low-probability event.

After the install, add your user to the docker group so you do not need sudo for every command:

sudo usermod -aG docker $USER

Log out and back in. Verify:

docker run hello-world

If you see "Hello from Docker!" you are good.

The arm64 vs amd64 gotcha

Docker images are built for specific CPU architectures. The Pi 4 and Pi 5 are arm64 (aarch64). Most desktop and server software is amd64 (x86_64). An amd64 image will not run on a Pi.

The good news: most popular services (Node-RED, Mosquitto, InfluxDB, Home Assistant, Pi-hole, Nextcloud) have official arm64 images. They work on the Pi.

The bad news: some niche services do not. The error looks like:

exec format error

The fix is to find an arm64 image or to rebuild from source. The quickest check is to look at the image's Docker Hub page for the "Platforms" or "Architectures" section. If it only lists linux/amd64, you are stuck.

If you are picking a service, prefer the ones with multi-arch images. If you are writing a Dockerfile for your own service, build it with --platform linux/arm64 from the start.

docker-compose for multi-container stacks

The pattern for "I run Node-RED + Mosquitto + InfluxDB on the same Pi" is a docker-compose.yml file. One file, all the services, one command to start them.

# docker-compose.yml
version: "3.9"

services:
  nodered:
    image: nodered/node-red:latest
    restart: unless-stopped
    ports:
      - "1880:1880"
    volumes:
      - nodered-data:/data

  mosquitto:
    image: eclipse-mosquitto:2
    restart: unless-stopped
    ports:
      - "1883:1883"
    volumes:
      - mosquitto-conf:/mosquitto/config
      - mosquitto-data:/mosquitto/data

  influxdb:
    image: influxdb:2
    restart: unless-stopped
    ports:
      - "8086:8086"
    volumes:
      - influxdb-data:/var/lib/influxdb2

volumes:
  nodered-data:
  mosquitto-conf:
  mosquitto-data:
  influxdb-data:

Save as docker-compose.yml and run:

docker compose up -d

The -d runs in the background. The three services start, expose their ports, and persist their data to the named volumes.

The restart: unless-stopped line means the services come back up after a reboot. Without it, you have to docker compose up -d again after every restart.

The "Pi 4 has limited RAM" gotcha

A Pi 4 with 4 GB of RAM runs about 4-6 typical services before the OOM killer starts reaping things. A Pi 5 with 8 GB runs about 8-12.

The OOM killer does not announce itself. Your service just disappears from docker ps and shows up in docker ps -a as "exited." The dmesg log has the murder.

The fix is to monitor RAM and either:

  • Upgrade to the 8 GB Pi.
  • Move the heavy services (InfluxDB, Grafana with a big database) to another machine.
  • Use SQLite instead of InfluxDB for small time-series datasets. SQLite is plenty for most home projects.

A Pi 4 with 4 GB is enough for "Pi-hole + Mosquitto + a small Node-RED flow." It is not enough for "Pi-hole + Mosquitto + InfluxDB + Grafana

  • Home Assistant + Nextcloud + Jellyfin." Pick the right workload for the hardware.

The bind mount pattern for persistent config

Docker volumes are managed by Docker, and you cannot easily get a file in or out of them. For service config files you want to edit on the host, use a bind mount:

services:
  mosquitto:
    image: eclipse-mosquitto:2
    volumes:
      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
      - mosquitto-data:/mosquitto/data

The ./mosquitto.conf path is on the host, mounted read-only into the container. Edit the file on the host, restart the container, the new config is in effect.

The rule: data you want to back up, edit, or version-control lives in bind mounts on the host. Data the service owns (databases, runtime state) lives in named volumes.

Pi 5 vs Pi 4 differences

The Pi 5 is a meaningful upgrade over the Pi 4 for Docker workloads:

  • 2-3x the CPU performance, depending on the workload.
  • Better I/O. The Pi 5 has a real PCIe lane for NVMe SSDs (via the official HAT), which is much faster than the Pi 4's USB 3.0 SSD.
  • 8 GB is now a sensible default, not a luxury.
  • It runs hotter. A passive heatsink is necessary; an active cooler is recommended.

The Pi 4 is still good for light workloads. The Pi 5 is the right choice for "I am running 5+ containers and I want them to be fast."

When to use Docker on Pi vs a full x86 server

A Pi is a good Docker host for:

  • A handful of small services.
  • A home network with low traffic.
  • Anything you do not want to pay $30/month to host.

A Pi is not a good Docker host for:

  • Many concurrent users (the network is the bottleneck, 1 Gbit ethernet is shared with USB).
  • Heavy I/O workloads (databases that need NVMe speeds).
  • Anything that needs more than 16 GB of RAM.
  • A 24/7 production service that cannot tolerate a Pi SD card failure.

The rule: a Pi is a good "home lab" or "small business" host. It is not a replacement for a real server in a production environment.

What you learned

  • Docker on a Pi gives you service isolation, reproducibility, and easy upgrade paths.
  • The arm64 architecture constraint is the most common Docker gotcha on the Pi.
  • docker-compose.yml is the spec for a multi-service stack. One file, one command to bring it up.
  • The Pi 4 with 4 GB is enough for a small stack, the Pi 5 with 8 GB is enough for a medium one.
  • Bind mounts are for config, named volumes are for data.

When something breaks

exec format error. The image is amd64-only. Find an arm64 image or rebuild from source.

The service keeps restarting. OOMKilled in docker ps. Out of RAM. Either reduce the workload, move heavy services elsewhere, or upgrade the Pi.

docker compose up says "port is already allocated". Another service on the host (or another container) is using the port. Change the host-side mapping in the ports: block.

The bind-mounted config file is not picked up. The container has it cached, or the file is in the wrong path. Restart the container after editing the file.

The Pi runs out of disk space. Old Docker images. Run docker system prune to remove unused ones.

What to build next

  • A full home automation stack (Node-RED + Mosquitto + InfluxDB + Grafana).
  • A Pi-hole + Unbound recursive DNS combo.
  • A Nextcloud instance for personal file sync.
  • A monitoring stack (Prometheus + Grafana + node-exporter).

The Pi-hole + Unbound stack is the easiest win. The Nextcloud instance is the most useful for a household.


Chapter 12

Raspberry Pi: back up and clone an SD card the safe way

raspberry-pi · 45 min

Every Pi I run eventually gets the same moment: the SD card dies, or I break the OS tinkering, or I need a second Pi configured exactly like the first. The projects that survive this are the ones with a current image file sitting on a shelf. This tutorial covers the three backup tools I actually use, which one for which job, and the checks that keep a backup from being an unreadable file you discover during the emergency.

The trap I hit: I made a "backup" with a drag-and-drop copy of the files onto a USB stick. Files copied, nothing worked, because an SD card is not just files. It has partitions, a bootloader area, and permissions you cannot reproduce by copying. A real backup is a byte-level image (or a filesystem-level rsync with the right flags). The drag-and-drop version is a comfort object.

What you need

Needed

  • The Raspberry Pi whose card you want to back up
  • A second SD card or a USB card reader (e.g. any USB 3 reader, $8)
  • A destination with more free space than the card's used space (a USB SSD or a spare drive; the image file is as big as the card)
  • A second computer (Windows, macOS, or Linux) for the imaging step, or a second Pi acting as the host

Nice to have

  • A second identical SD card for a "warm spare" you can swap in minutes
  • A USB SSD (faster than a spinning drive for image reads and writes)
  • A card reader with a write-protect switch for archiving golden images
  • A label or index card: the image date and what Pi it belongs to
  • Multimeter not needed here; this one is pure software

The three tools and when each one wins

Job Tool Why
Restore a fresh OS or reflash Raspberry Pi Imager Fast, official, writes with verification
Full byte-for-byte backup dd (plus gzip) Exact copy of everything, boot area included
Scheduled file-level backup rsync Fast, incremental, restartable

A full image of a 32 GB card takes 25-30 minutes at USB 3 speeds and eats 32 GB of disk. Rsync of the same card's data might be 4 GB and two minutes. Most Pi owners should be running rsync weekly and making a full image only at milestones (right after setup works, right before a risky change, right after a risky change succeeds).

Setup: identify the card correctly

This is the safety-critical part. dd writes to a device, and if you aim it at the wrong one it will happily overwrite your own drive with zero confirmation. Always identify twice, write once.

On Linux (or a Pi acting as host), insert the reader and run:

lsblk

The card shows up as a disk with its size (e.g. sdb 32G with two partitions sdb1 and sdb2). Cross-check with:

sudo fdisk -l

If your destination drive also shows up here, note both names and keep them on screen while you work. The pattern if= means input file and of= means output file; getting those two backwards is the classic data-losing typo, and it happens to experienced people on bad days.

Method 1: full image with dd

Boot nothing, just the card in a reader on a Linux box (or another Pi):

sudo dd if=/dev/sdb of=pi-backup-$(date +%F).img bs=4M status=progress conv=fsync
sync

That is a raw image of the entire card, bootloader included, named with today's date. bs=4M is speed, status=progress is sanity, and conv=fsync means the data is actually on disk when the command returns. The sync afterwards is for the write cache.

The image is the full card size even if the card is mostly empty. Shrink it with gzip:

sudo dd if=/dev/sdb bs=4M status=progress | gzip > pi-backup-$(date +%F).img.gz

A mostly-empty 32 GB card typically compresses to 2-6 GB. Restore is the reverse direction:

gunzip --stdout pi-backup-2026-09-23.img.gz | sudo dd of=/dev/sdb bs=4M status=progress conv=fsync
sync

Never write to the card while its partitions are mounted. If lsblk shows the partitions mounted (e.g. /media/pi/bootfs), unmount them first: sudo umount /dev/sdb1 /dev/sdb2. Imaging a mounted card gives you a corrupt image and a possibly corrupt card.

On Windows and macOS, the same job is one GUI app: Raspberry Pi Imager >> Choose OS >> scroll down to "Use custom" (pick your saved .img file), and for backups there is a "read from card" flow in the Imager's OS chooser menu (Choose OS >> misc utility images; on Windows builds, use the reader's drive letter with the OS-level disk tool diskpart/diskutil to identify it first). The GUI is slower than dd but shows a progress bar and verifies the write.

Method 2: rsync, the backup that runs weekly

The image is the milestone backup. Rsync is the rhythm. This is what keeps a running Pi safe week to week:

sudo rsync -aAXHv --delete --exclude={"/proc/*","/sys/*","/dev/*","/tmp/*","/run/*","/mnt/*","/media/*"} / /mnt/backup/pi-root/

The flags matter: -a preserves permissions and ownership, -A and -X preserve ACLs and extended attributes, -H preserves hard links, --delete makes the copy a mirror instead of an archive pile, and the excludes keep the Pi from copying its own live system directories (which would loop and fill the drive).

Run it from cron for an automatic weekly copy:

# /etc/cron.d/pi-backup
0 3 * * 1  root rsync -aAXH --delete --exclude={"/proc/*","/sys/*","/dev/*","/tmp/*","/run/*"} / /mnt/backup/pi-root/ >> /var/log/pi-backup.log 2>&1

Restoring from rsync is a copy in the other direction onto a freshly flashed card, then fixing the boot partition from a fresh Raspberry Pi OS image if the bootloader is what died. It is not byte-identical, but it preserves every file, permission, and config you care about, and it is small enough to run daily if you want.

Method 3: Raspberry Pi Imager for restores and spares

Raspberry Pi Imager >> Choose OS >> Raspberry Pi OS (other) >> pick the version, then Choose Storage >> the target card. Before writing, hit the gear icon (Imager >> OS customization) and preset the hostname, SSH, username, and Wi-Fi. A configured image means the replacement Pi boots straight into SSH with no monitor and no keyboard.

The "warm spare" habit is worth the cost of one extra card: image the working Pi's card once, then write that image onto the spare card, and the spare lives in a drawer labeled with the date. Card dies Tuesday night, swap in the spare Wednesday morning, then rsync the recent files back on top. Downtime is one boot cycle.

The code

A small script that wraps the milestone image with the checks that make it trustworthy (size sanity, checksum, and a note in a backup log):

#!/usr/bin/env python3
"""sd_image.py: image an SD card with sanity checks and a checksum."""
import hashlib
import subprocess
import sys
from datetime import date

DEVICE = sys.argv[1] if len(sys.argv) > 1 else ""       # e.g. /dev/sdb
DEST = sys.argv[2] if len(sys.argv) > 2 else "/mnt/backup"
BLOCK = "4M"

def sh(cmd, timeout=None):
    return subprocess.run(cmd, shell=True, capture_output=True, text=True,
                          timeout=timeout)

def human(n):
    for unit in ("B", "K", "M", "G"):
        if n < 1024:
            return f"{n:.0f} {unit}"
        n /= 1024
    return f"{n:.1f} T"

def main():
    if not DEVICE:
        print("usage: sd_image.py /dev/sdX /mnt/backup")
        return 1
    # 1. device must exist and look like a disk
    r = sh(f"lsblk -b -n -o SIZE,TYPE {DEVICE}")
    if r.returncode != 0 or "disk" not in r.stdout:
        print(f"refusing: {DEVICE} is not a disk (lsblk says: {r.stdout!r})")
        return 1
    size = int(r.stdout.split()[0])
    print(f"target {DEVICE} size {human(size)}")
    if size < 1_000_000_000:
        print("refusing: suspiciously small device")
        return 1
    # 2. partitions must not be mounted
    r = sh(f"lsblk -n -o MOUNTPOINT {DEVICE}")
    mounts = [line.strip() for line in r.stdout.splitlines() if line.strip()]
    if mounts:
        print(f"refusing: partitions are mounted: {mounts}")
        print("unmount them first, e.g. sudo umount /dev/sdb1 /dev/sdb2")
        return 1
    # 3. image it
    stamp = date.today().isoformat()
    out = f"{DEST}/pi-backup-{stamp}.img"
    cmd = (f"sudo dd if={DEVICE} of={out} bs={BLOCK} status=progress "
           f"conv=fsync && sync")
    print("imaging...", flush=True)
    r = sh(cmd, timeout=7200)
    if r.returncode != 0:
        print(f"dd failed: {r.stderr[-500:]}")
        return 1
    # 4. checksum so a later restore is provable
    h = hashlib.sha256()
    with open(out, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    digest = h.hexdigest()
    with open(out + ".sha256", "w") as f:
        f.write(f"{digest}  {out}\n")
    print(f"done: {out}")
    print(f"sha256: {digest}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

The refusals are the point. A backup script that can destroy a mounted drive or pick the wrong device is a loaded footgun; this one checks the device type, the size, and the mount state before it writes a byte, and it leaves a checksum so the image can be verified before a restore.

Verify the backup before you trust it

A backup you have never restored from is a hypothesis. The cheap verification, in order:

  1. The checksum matches (sha256sum -c pi-backup-2026-09-23.img.sha256).
  2. The image size matches the card size from lsblk.
  3. Mount the image read-only and look inside (Linux only, loop devices): sudo losetup -fP pi-backup-2026-09-23.img then list /mnt/loop2p2 (or wherever the root partition lands) and check /etc/hostname and /home.
  4. The gold standard: write it to a spare card and boot a Pi from it.

Items 1-3 take ten minutes and catch 90 percent of failures (truncated images, wrong device, corrupted writes). Item 4 is for the images you will bet an outage on.

What you learned

  • Drag-and-drop is not a backup of a Pi. You need an image or a filesystem-level copy.
  • dd makes exact images; gzip keeps them small; conv=fsync keeps them real.
  • rsync with -aAXH --delete and system-dir excludes is the weekly rhythm.
  • Verify (checksum, mount, boot) before you trust an image.

When something breaks

  • The image will not restore, or the restored card will not boot. Truncated image (disk filled during dd) is the usual cause. Check the file size against the card size, re-image with free space confirmed, and always conv=fsync plus sync.
  • dd finished instantly and the card is empty. You swapped if= and of=. The destination drive may now hold the SD's old data (the card's content is gone from the card but is likely ON the drive you mis-targeted). Stop writing to that drive and image it as the source to recover.
  • Rsync fills the destination drive. You forgot the excludes and it followed /proc and looped, or --delete was missing and every run added a full copy. Add the excludes and --delete, and prune old snapshots.
  • The restored Pi boots but the filesystem is read-only. The image was written to a card with a flaky connection or the card is worn out. Try a different card; SD cards have finite writes and old cards fail exactly like this.
  • Windows will not read the ext4 card or image. Normal. Windows cannot read ext4 natively; use WSL, or ext4-capable tools, or do the verify step on a Linux box.

What to build next

  • The Samba NAS tutorial: put /mnt/backup on a Pi NAS and your image and rsync jobs land on a second machine automatically.
  • The SQLite logging tutorial: application-level data survives even when the OS image is a milestone behind.
  • Pair this with the 4G LTE HAT tutorial for remote sites: rsync over the WireGuard bridge nightly, and the shed Pi's config is always one image plus one sync away from restored.

© ctrlaltbrian.com

Published 2026-09-24 · Source: ctrlaltbrian.com

Built in the spirit of measure twice, flash once. Brian writes these so you can actually finish the project, not so you give up halfway and buy a pre-made one.

© 2026 ctrlaltbrian. Code samples are MIT. Tutorials are CC BY-NC-SA 4.0 (use them, share them, don't resell them).