How to Build NomadNet Nodes on a Raspberry Pi: A Complete Tutorial

Date

Date

April 23, 2026

April 23, 2026

Author

Author

Lisa Zhao

Lisa Zhao

A step-by-step guide to building Reticulum mesh network nodes that serve pages, RSS feeds, guestbooks, and emergency alerts over radio and TCP. Written for someone who can follow commands in a terminal but doesn't necessarily know what every command does yet.

By the end of this tutorial you'll have:

  1. A working Reticulum mesh network on a Raspberry Pi

  2. At least one NomadNet node serving pages

  3. Dynamic pages with forms and user-submitted content

  4. Automated RSS feed fetching

  5. Multiple independent nodes running on the same Pi

  6. Automatic backups to a USB drive

  7. A framework for adding more nodes and pages

This tutorial can be given to Claude (or any capable LLM assistant) as context. If you get stuck, paste this document in alongside your question and the assistant will have enough background to help.

Part 1: Understanding What You're Building

What is Reticulum?

Reticulum is a decentralised networking stack. Think of it like the internet, but built from the ground up to run over anything: long-range radio (LoRa), short-range radio, WiFi, I2P tunnels, plain old TCP. Every node on the network has a cryptographic identity. Every message is encrypted end-to-end by default.

You don't need an ISP for Reticulum to work. Two devices with LoRa radios can talk to each other directly over kilometres of distance, and if a third device is in range of both, it can act as a relay. The network keeps working even if the internet is down.

What is NomadNet?

NomadNet is a piece of software that runs on top of Reticulum. It does two things:

  1. Lets you send encrypted messages to other Reticulum users

  2. Serves pages from your device that other Reticulum users can browse

Think of the page server like a tiny dark-web site, except it runs over Reticulum instead of Tor. People don't type in a domain name. They browse to your node's cryptographic address (a short string like 4710b86ba18c42d1d5f30f74fe509286) and see whatever pages you're hosting.

What is MeshChat?

MeshChat is a browser-based front-end for Reticulum. Instead of a terminal UI, you get a clean web interface where you can send messages, see who else is on the network, and browse other people's NomadNet nodes. You'll use MeshChat to browse your own pages and confirm they look right.

Why build multiple nodes?

One Pi can run multiple NomadNet instances, each with its own identity and purpose. In this tutorial you'll build three:

  • A main node (for a guestbook, visitor count, tutorial pages)

  • A news node (aggregates RSS feeds from Canadian news sources)

  • An emergency warnings node (aggregates official alert feeds)

Each node announces itself separately on the network, so someone browsing from another city would see three distinct nodes, not one big one.

Part 2: What You Need

Hardware

  • A Raspberry Pi. A Pi 3B+ or newer works fine. You want at least 1GB of RAM.

  • A microSD card, 32GB or larger. Get a decent brand (SanDisk, Samsung). Cheap SD cards fail.

  • A power supply for the Pi.

  • Network access. Ethernet cable or WiFi both work.

  • (Optional but recommended) A USB stick for backups. 8GB is plenty.

  • (Optional) A LoRa radio device like a LilyGo LoRa32 or Heltec LoRa32 if you want radio connectivity. This tutorial works without one, using TCP over the internet only.

Software skills

You should be comfortable with:

  • Opening a terminal

  • Running commands over SSH

  • Editing text files (using nano is fine, no need for vim)

  • Copying files between your laptop and the Pi using scp

If you've never SSH'd into a computer, spend an hour learning that first. The rest of this tutorial assumes you have it down.

Time

First-time setup takes 4 to 6 hours if nothing breaks. Budget a full weekend for your first attempt. It gets much faster the second time.

Part 3: Flashing the SD Card

Step 1: Install Raspberry Pi Imager

Download Raspberry Pi Imager from raspberrypi.com. Install it on your laptop.

Step 2: Configure the Image

Plug your SD card into your laptop. Open Raspberry Pi Imager and:

  1. Choose device: your Pi model

  2. Choose OS: Raspberry Pi OS Lite (64-bit). "Lite" means no desktop, which is what you want for a server.

  3. Choose storage: your SD card

Before you click Write, click the Settings gear icon. You need to pre-configure a few things:

  • Set hostname: pick something memorable. You'll use this to SSH in. Examples: my-node, mesh-pi, nodeserver. Avoid dots, spaces, special characters. In this tutorial we'll use mynode as the example hostname.

  • Set username and password: pick a username you'll remember. In this tutorial we'll use user as the example. Use a strong password.

  • Configure wireless LAN: if using WiFi, enter your network SSID and password.

  • Enable SSH: turn this on. Password authentication is fine for a home network.

  • Set locale: pick your timezone.

Click Save, then Write. This takes about 5-10 minutes.

Step 3: Boot the Pi

When writing is done, eject the card, put it in the Pi, plug in network cable (or just power for WiFi), plug in power. Wait 3-5 minutes for first boot.

Step 4: Connect via SSH

From your laptop's terminal:

ssh

Replace user with whatever username you set, and mynode with your hostname. Type yes to accept the SSH key, then enter your password.

If it says "connection refused", wait another 2 minutes and try again. First boot does a bunch of setup work that can temporarily block SSH.

If it says "could not resolve hostname", your router may not be doing mDNS properly. Find the Pi's IP address from your router admin page and SSH to that IP directly instead.

Part 4: Installing Reticulum

Step 1: Update the system

First things first, update everything:

sudo apt update
sudo apt upgrade -y

This takes about 10 minutes on a Pi 3. Let it finish.

Step 2: Install Python tools and dependencies

sudo apt install -y python3-pip git

Step 3: Add swap space (recommended for Pi 3)

The Pi 3B+ only has 1GB of RAM. Running multiple Python services can push it into out-of-memory territory. Add 512MB of swap as a safety net:

if [ ! -f /var/swap ]; then
    sudo fallocate -l 512M /var/swap
    sudo chmod 600 /var/swap
    sudo mkswap /var/swap
    sudo swapon /var/swap
    echo '/var/swap none swap sw 0 0' | sudo tee -a /etc/fstab
fi

This creates a 512MB file at /var/swap, turns it into swap space, and adds it to /etc/fstab so it's automatically enabled on boot.

Step 4: Install Reticulum itself

pip3 install rns lxmf --break-system-packages

The --break-system-packages flag is needed on recent Raspberry Pi OS versions because Debian tries to prevent pip from installing packages globally. For our purposes, it's fine.

Step 5: Fix the PATH

Newly installed Python scripts go into ~/.local/bin/ but that's not in your shell's search path by default. Fix that:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source

Test it:

Should return something like /home/user/.local/bin/rnsd. If it says "not found", the PATH setup didn't take effect.

Step 6: Configure Reticulum

Now configure Reticulum to connect to the global mesh network. Create the config file:

mkdir -p

Paste this in:

[reticulum]
  enable_transport = True
  discover_interfaces = yes

[interfaces]

  [[Default Interface]]
    type = AutoInterface
    enabled = Yes

  [[rmap.world]]
    type = TCPClientInterface
    interface_enabled = yes
    target_host = rmap.world
    target_port = 4242

  [[dismail.de]]
    type = TCPClientInterface
    interface_enabled = yes
    target_host = rns.dismail.de
    target_port = 7822

  [[reticulum.n7ekb.net]

Save with Ctrl+O, Enter, then Ctrl+X.

What this does: The three TCP interfaces connect you to public community nodes that bridge between isolated Reticulum mesh segments. Without these, your Pi can only talk to other devices on your local network.

Step 7: Run rnsd as a service

rnsd (Reticulum Network Stack Daemon) is the background process that keeps the network running. Run it as a systemd service so it starts automatically at boot:

sudo tee /etc/systemd/system/rnsd.service > /dev/null << 'EOF'
[Unit]
Description=Reticulum Network Stack Daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=user
ExecStart=/home/user/.local/bin/rnsd
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

Important: replace user with your actual username in both the User= line and the ExecStart= path. If you're not sure, run whoami to check.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable rnsd
sudo systemctl start

Check it's running:

sudo

You should see active (running) in green.

Step 8: Verify the network is live

Wait 30 seconds for connections to establish. You should see all three TCP interfaces with Status: Up. If any are Down, your ISP might be blocking that port (some ISPs block non-standard ports). One out of three working is enough to stay connected.

Part 5: Installing MeshChat

MeshChat is a web-based browser for Reticulum. You'll use it to browse your NomadNet nodes as you build them.

Step 1: Clone and install

cd ~
git clone https://github.com/liamcottle/reticulum-meshchat
cd reticulum-meshchat
pip3 install -r requirements.txt --break-system-packages

Step 2: Set up as a service

sudo tee /etc/systemd/system/meshchat.service > /dev/null << 'EOF'
[Unit]
Description=Reticulum MeshChat
After=rnsd.service

[Service]
Type=simple
User=user
WorkingDirectory=/home/user/reticulum-meshchat
ExecStart=/usr/bin/python3 meshchat.py --host 0.0.0.0
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable meshchat
sudo systemctl start

Replace user with your username in three places.

Step 3: Access MeshChat

From your laptop's browser, go to:

Replace mynode with your Pi's hostname. If that doesn't work, try the Pi's IP address instead. You should see the MeshChat interface.

Part 6: Your First NomadNet Node

Step 1: Install NomadNet

pip3 install nomadnet --break-system-packages

Step 2: Generate the default config

Run NomadNet once to create its config directory. Let it start, then kill it:

nomadnet --daemon &
sleep 8
pkill -f

This creates ~/.nomadnetwork/ with default config files.

Step 3: Enable node hosting

Open the config:

Find the [node] section (use Ctrl+W to search). Change these settings:

[node]

What each setting means:

  • enable_node: makes your device actually serve pages. Without this, NomadNet runs but doesn't host.

  • node_name: what other people see when your node announces itself. Can include emoji.

  • announce_interval: how often (in minutes) your node broadcasts its existence. Lower numbers mean new people find you faster, but use more airtime.

  • announce_at_start: send an announce the moment the service starts, don't wait for the first interval.

Save and exit.

Step 4: Run as a service

sudo tee /etc/systemd/system/nomadnet.service > /dev/null << 'EOF'
[Unit]
Description=NomadNet Page Server
After=rnsd.service

[Service]
Type=simple
User=user
ExecStart=/home/user/.local/bin/nomadnet --daemon
Restart=on-failure
RestartSec=30
StartLimitIntervalSec=300
StartLimitBurst=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable nomadnet
sudo systemctl start nomadnet
sudo

Replace user with your actual username. Should show active (running).

Step 5: Find your node in MeshChat

In MeshChat, click the Announces tab (looks like a broadcast tower icon). Wait up to 2 minutes for your node to show up. You should see "My First Node" (or whatever you called it) appear.

Click it, and you'll see... probably nothing, because you haven't made any pages yet. That's what the next section is about.

Part 7: Understanding Micron Markup

Pages in NomadNet are written in a markup language called Micron. It's designed to work over extremely low-bandwidth links, so it's minimal.

Static vs Dynamic Pages

There are two kinds of pages:

  1. Static pages: plain text files with Micron markup. Just text. The server sends them as-is.

  2. Dynamic pages: executable scripts (usually Python) that print Micron markup to standard output. The server runs them and sends whatever they print.

The difference comes down to whether the file has the executable permission bit set. Static pages should NOT be executable. Dynamic pages MUST be executable.

This is important and has bitten me more than once. If you accidentally make a static page executable, NomadNet tries to run it as a script, fails, and the page hangs forever with no error message.

Micron Formatting Codes

Every formatting code starts with a backtick (`):

Code

Effect

`c

Center align

`l

Left align

`!

Toggle bold

`*

Toggle italic

`_

Toggle underline

`Frgb

Foreground colour, 3-digit hex (e.g. `Fd00 is red)

`Brgb

Background colour, 3-digit hex

`f

Reset foreground

`b

Reset background

`` (two backticks)

Reset everything

>Heading

Heading (starts with >)

---

Horizontal divider

Important: bold is `! not `*. The asterisk is italic. Using asterisks inside link labels can break your page because the parser interprets them as formatting commands.

Links

The path must include /page/ for internal pages. Example:


Simple Index Page Example

`c`Bd00`Ffff  MY FIRST NODE
`c`Bfff`Fd00  A Reticulum Mesh Experiment
``

>About

This is a demo node running on a Raspberry Pi.

---

>Pages

The double backtick `` after the red-banner heading resets all formatting so the rest of the page isn't still red and on a red background.

Part 8: Writing Static Pages

Pages live in ~/.nomadnetwork/storage/pages/. The main page is index.mu.

Step 1: Create the pages directory

mkdir -p

Step 2: Create your index page

Paste the example index page from the previous section. Save and exit.

Step 3: Make sure it's NOT executable

chmod 644

The 644 permission means readable by everyone, writable only by you, not executable. This is correct for static pages.

Step 4: Reload NomadNet

NomadNet scans the pages directory on startup. Restart it to pick up new pages:

sudo systemctl restart

Step 5: View your page

In MeshChat, go to your node again. You should now see your index page rendered.

Adding more static pages

Just create more .mu files in the same directory. Link to them from index.mu using the `[Label`:/page/filename.mu] syntax. Give them all 644 permissions.

Example: create about.mu:

`c`Bd00`Ffff  MY FIRST NODE
`c`Bfff`Fd00  About
``

>Who

I built this node in (your city) as a demo of Reticulum mesh networking.

>Why

Because the internet is great until it isn't. Mesh networks work even
when the internet is down. That matters for emergencies, for regions
with poor connectivity, and for anyone who wants to communicate without
depending on centralised infrastructure.

>Hardware

Then:

chmod 644

No NomadNet restart needed for individual pages. Just refresh in MeshChat.

Part 9: Writing Dynamic Pages

Dynamic pages are Python scripts. They do whatever you want, then print Micron markup to stdout. Common uses:

  • A visitor counter that increments on each page load

  • A guestbook where people leave messages

  • An RSS feed reader that displays news headlines

  • A form that accepts input and stores it

The Basic Structure

Every dynamic page looks like this:

#!/usr/bin/env python3
#!c=0

# Your logic here

print("`c`Fd00 Hello from dynamic page!")
print("")
print("`[Back`:/page/index.mu]")

Two important lines at the top:

  1. #!/usr/bin/env python3 is the shebang line. This tells Linux "run this file using Python 3". Every executable Python script needs this.

  2. #!c=0 is a NomadNet directive that disables caching. Without this, dynamic pages might return stale content because NomadNet remembers their output. Always include this for dynamic pages.

Example: Visitor Counter

Create counter.mu:

#!/usr/bin/env python3
#!c=0

import os

COUNT_FILE = os.path.join(os.path.dirname(__file__), "visit_count.txt")

# Read current count
try:
    with open(COUNT_FILE, "r") as f:
        count = int(f.read().strip())
except (FileNotFoundError, ValueError):
    count = 0

# Increment and save
count += 1
with open(COUNT_FILE, "w") as f:
    f.write(str(count))

# Render the page
print("`c`Bd00`Ffff  VISITOR COUNT")
print("``")
print("")
print(f"`c`Fd00 This page has been viewed {count} times")
print("")
print("---")
print("`[Back`:/page/index.mu]")

Save, then make it executable:

chmod 755

The 755 permission means executable by everyone, writable only by you. Required for dynamic pages.

Test it from the command line:

You should see the Micron markup printed. Each run increments the counter.

Add a link to it from your index.mu:

Browse to it in MeshChat. Refresh the page a few times. The count goes up.

Part 10: Forms and Guestbooks

This is where NomadNet gets interesting. You can accept input from visitors.

How Forms Work

NomadNet passes form field values to your script as environment variables. If you have a field called username, its value shows up as an environment variable ending in username (the exact prefix varies by client).

The trick is that MeshChat and NomadNet's own TUI use slightly different prefixes. A robust script scans all environment variables looking for any that end with your field name.

The Guestbook Script

#!/usr/bin/env python3
#!c=0

import os, time

GUESTBOOK_FILE = os.path.join(os.path.dirname(__file__), "guestbook.txt")

def recover_input(key_suffix):
    """Find an environment variable whose name ends with the given suffix."""
    for k, v in os.environ.items():
        if k.lower().endswith(key_suffix):
            return v.strip()
    return ""

# Pull the form values
message = recover_input("message")
username = recover_input("username") or "Anonymous"

# If a message was submitted, save it
if message:
    username = username.replace("`", "").replace("\n", "")[:20] or "Anonymous"
    message = message.replace("`", "").replace("\n", " ")[:120]
    timestamp = time.strftime("%Y-%m-%d %H:%M")
    entry = f"{timestamp} | {username}: {message}\n"
    with open(GUESTBOOK_FILE, "a") as f:
        f.write(entry)

# Read existing entries
entries = []
if os.path.isfile(GUESTBOOK_FILE):
    with open(GUESTBOOK_FILE, "r") as f:
        entries = f.readlines()

# Render the page
print("`c`Bd00`Ffff  GUESTBOOK")
print("``")
print("")
print("`c`Fd00 * Leave a message for future visitors *")
print("")
print("---")
print("")
print(">Your Message")
print("")
print("Nickname:")
print("`<username`>")
print("")
print("Message:")
print("`<40|message`>")
print("")
print("`[Submit`:/page/guestbook.mu`username|message]")
print("")
print("---")
print("")
print(">Recent Messages")
print("")

if entries:
    for entry in reversed(entries[-20:]):
        print("`F888" + entry.strip())
        print("")
else:
    print("`F888 No messages yet. Be the first!")

print("")
print("---")
print("`[Back`:/page/index.mu]")

Make it executable:

chmod 755

Understanding the Form Syntax

Look at these three lines:


The `<username`> creates a text input field named username.

The `<40|message`> creates a text input 40 characters wide named message.

The submit button syntax is:

The pipe-separated list after the second backtick tells NomadNet which form fields to submit.

Sanitisation

The script does three important things for security:

  1. Strip backticks: ` is the Micron markup character. If a visitor types a backtick into their message, it could break the page rendering. Remove them.

  2. Strip newlines: messages should be one line. Newlines could break the log format.

  3. Truncate length: cap nickname at 20 characters, message at 120. Prevents someone pasting in a novel.

Add a link to it from index.mu and browse from MeshChat. You should be able to type a nickname and message, hit Submit, and see your message appear in the list.

Part 11: Running Multiple Nodes

One Pi can run multiple NomadNet instances, each with its own identity, name, and pages. This is where things get powerful.

Each instance needs:

  • Its own config directory (like ~/.nomadnetwork_news/ instead of ~/.nomadnetwork/)

  • Its own systemd service with a different name

  • The --config flag on the nomadnet command line pointing at the right directory

Step 1: Create a new config directory

Let's build a "News Node":

mkdir -p

Step 2: Generate the config

Run NomadNet once with the new config path to create a default config:

nomadnet --config ~/.nomadnetwork_news --daemon &
sleep 8
pkill -f "config /home/user/.nomadnetwork_news"

Replace user with your actual username.

Step 3: Edit the new config

Find the [node] section and change:


Step 4: Create the new systemd service

sudo tee /etc/systemd/system/nomadnet_news.service > /dev/null << 'EOF'
[Unit]
Description=NomadNet News Node
After=rnsd.service

[Service]
Type=simple
User=user
ExecStart=/home/user/.local/bin/nomadnet --config /home/user/.nomadnetwork_news --daemon
Restart=on-failure
RestartSec=30

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable nomadnet_news
sudo systemctl start

Step 5: Verify both are running

Should print active twice.

In MeshChat, you should now see two nodes in your Announces tab: your original node and the News Node.

Add pages to the news node the same way as before, but in the new directory:

Part 12: Aggregating RSS Feeds

Now let's do something useful with the news node: pull in real RSS feeds from external sources, cache them locally, and display them.

Why cache?

Every time someone browses your news page, we could have the script fetch the RSS feed live. But that would:

  • Be slow (waits on internet)

  • Consume bandwidth unnecessarily

  • Break if the internet was down (defeating the point of mesh networking)

Instead, a separate script runs every 30 minutes on a schedule. It fetches all feeds and writes them to cache files. The page script just reads the cache. The page loads instantly even if the internet is down.

Step 1: Write the fetcher

#!/usr/bin/env python3
# Cache fetcher - run via crontab every 30 minutes

import urllib.request
import xml.etree.ElementTree as ET
import os, time, html, re

CACHE_DIR = os.path.expanduser("~/.nomadnetwork_news/cache")
os.makedirs(CACHE_DIR, exist_ok=True)

FEEDS = {
    "cbc": {
        "name": "CBC News",
        "url": "https://www.cbc.ca/cmlink/rss-topstories",
        "file": "cbc.txt"
    },
    "globemail": {
        "name": "The Globe and Mail",
        "url": "https://www.theglobeandmail.com/arc/outboundfeeds/rss/category/canada/",
        "file": "globemail.txt"
    },
    # Add more feeds here following the same pattern
}

def clean(text):
    """Strip HTML tags and normalise whitespace."""
    if not text:
        return ""
    text = re.sub(r'<[^>]+>', '', text)
    text = html.unescape(text)
    text = text.replace('`', "'").replace('\n', ' ').replace('\r', '')
    return text.strip()

def fetch_feed(feed_id, feed_info):
    try:
        req = urllib.request.Request(
            feed_info["url"],
            headers={"User-Agent": "Mozilla/5.0 MeshNode/1.0"}
        )
        response = urllib.request.urlopen(req, timeout=15)
        raw = response.read().decode('utf-8', errors='replace').lstrip('\ufeff')
        raw = re.sub(r'<\?xml[^?]*\?>\s*', '', raw, count=1)
        raw = '<?xml version="1.0" encoding="utf-8"?>' + raw
        root = ET.fromstring(raw.encode('utf-8'))

        items = (root.findall(".//item") or
                 root.findall(".//{http://www.w3.org/2005/Atom}entry"))

        lines = [
            f"FETCHED:{int(time.time())}",
            f"NAME:{feed_info['name']}",
            f"COUNT:{min(len(items), 10)}"
        ]

        for item in items[:10]:
            title = clean(item.findtext("title") or "")
            desc = clean(item.findtext("description") or "")
            pub = clean(item.findtext("pubDate") or "")
            if len(desc) > 150:
                desc = desc[:150] + "..."
            lines += [f"TITLE:{title}", f"DATE:{pub[:16]}",
                      f"DESC:{desc}", "---"]

        with open(os.path.join(CACHE_DIR, feed_info["file"]), "w",
                  encoding="utf-8") as f:
            f.write("\n".join(lines))
        print(f"[OK] {feed_info['name']} - {len(items)} items")

    except Exception as e:
        print(f"[ERR] {feed_info['name']}: {e}")

for fid, finfo in FEEDS.items():
    fetch_feed(fid, finfo)
print("Done.")

Step 2: Run it manually to test

You should see [OK] lines for each feed. Any [ERR] means that specific feed couldn't be reached.

Step 3: Create the feed display page

#!/usr/bin/env python3
#!c=0

import os, time

CACHE_DIR = os.path.expanduser("~/.nomadnetwork_news/cache")
CACHE_FILE = os.path.join(CACHE_DIR, "cbc.txt")
FEED_NAME = "CBC News"

def render():
    print("`c`Bd00`Ffff  NEWS NODE")
    print("``")
    print(f"`c`Fd00 * {FEED_NAME} *")

    if not os.path.exists(CACHE_FILE):
        print("`Fd00 Feed not yet cached.")
        print("[Back`:/page/index.mu]")
        return

    with open(CACHE_FILE, "r", encoding="utf-8") as f:
        lines = f.read().splitlines()

    data, items, current = {}, [], {}
    for line in lines:
        if line.startswith("FETCHED:"):
            data["fetched"] = int(line.split(":", 1)[1])
        elif line.startswith("TITLE:"):
            current["title"] = line.split(":", 1)[1]
        elif line.startswith("DATE:"):
            current["date"] = line.split(":", 1)[1]
        elif line.startswith("DESC:"):
            current["desc"] = line.split(":", 1)[1]
        elif line == "---" and current:
            items.append(current)
            current = {}

    if "fetched" in data:
        age = int((time.time() - data["fetched"]) // 60)
        print(f"`c`F888 Cached {age} minutes ago")
    print("")
    print("---")
    print("")

    for item in items:
        print(f"`!{item.get('title', 'No title')}``")
        if item.get("date"):
            print(f"`F888{item['date']}")
        if item.get("desc"):
            print(f"`f{item['desc']}")
        print("")

    print("---")
    print("[Back to News`:/page/index.mu]")

render()

Make it executable:

chmod 755

Step 4: Update the news index

`c`Bd00`Ffff  NEWS NODE
``
`c`F888 Cached every 30 min

---

>News Feeds

[CBC News`:/page/feed_cbc.mu]
[The Globe and Mail`:/page/feed_globemail.mu]

Note: create feed_globemail.mu using the feed_cbc.mu template, just changing CACHE_FILE and FEED_NAME.

Step 5: Automate fetching with cron

crontab -e

Add:

Replace user with your username. This runs the fetcher every 30 minutes and logs output to a file.

Check the cron job is installed:

crontab -l

Part 13: Backups

If your SD card dies, you lose everything. Even if you haven't run for long, back up early and often. Use a USB drive plugged into the Pi.

Step 1: Prepare the USB drive

Plug in a USB drive. Find its device name:

Look for something like sda with a partition sda1. That's your USB drive (as opposed to mmcblk0 which is the SD card).

Check what filesystem it has:

sudo

If it's FAT32 or exFAT, we'll reformat to ext4 for cleaner Linux permissions. If there's anything you want to keep on the drive, copy it off first.

Step 2: Format to ext4

sudo umount /dev/sda1 2>/dev/null
sudo mkfs.ext4 -L

Type y to confirm. This takes about 30 seconds.

Step 3: Set up persistent mount

Get the UUID:

sudo

Copy the UUID value. It looks like d64d7bf2-b1e7-4369-a37c-002a54d22f29.

Create the mount point:

sudo mkdir -p

Add to fstab so it mounts automatically on boot:

echo "UUID=YOUR-UUID-HERE /mnt/backup ext4 defaults,nofail,x-systemd.device-timeout=10 0 2" | sudo tee -a

Replace YOUR-UUID-HERE with your actual UUID.

What nofail does: if the USB drive isn't plugged in at boot, the Pi still boots normally. Without this, a missing USB drive can drop the Pi into recovery mode. Include it.

Mount it:

sudo systemctl daemon-reload
sudo mount -a
sudo chown

Replace user with your username.

Important detail: chown must run AFTER mounting. If you chown the empty mount point before mounting the drive, the drive's own root filesystem gets mounted on top and your ownership change is hidden. This has bitten me.

Test it:

touch /mnt/backup/test && rm /mnt/backup/test && echo "write works"

Step 4: Write the backup script

#!/bin/bash
# Daily backup script

BACKUP_DIR=/mnt/backup
DATE=$(date +%Y-%m-%d)
ARCHIVE="$BACKUP_DIR/backup_${DATE}.tar.gz"
LOG="$BACKUP_DIR/backup.log"
KEEP_DAYS=30

exec >> "$LOG" 2>&1

echo ""
echo "===== Backup started $(date) ====="

if ! mountpoint -q "$BACKUP_DIR"; then
    echo "ERROR: $BACKUP_DIR is not mounted"
    exit 1
fi

crontab -l > /tmp/crontab_backup.txt 2>/dev/null || echo "(no crontab)" > /tmp/crontab_backup.txt

tar --ignore-failed-read -czf "$ARCHIVE" \
    /home/user/.reticulum \
    /home/user/.nomadnetwork \
    /home/user/.nomadnetwork_news \
    /home/user/fetch_feeds.py \
    /home/user/backup.sh \
    /tmp/crontab_backup.txt \
    /etc/systemd/system/nomadnet.service \
    /etc/systemd/system/nomadnet_news.service \
    /etc/systemd/system/rnsd.service \
    /etc/systemd/system/meshchat.service

rm -f /tmp/crontab_backup.txt

if [ -f "$ARCHIVE" ]; then
    SIZE=$(du -h "$ARCHIVE" | cut -f1)
    echo "Created: $ARCHIVE ($SIZE)"
fi

find "$BACKUP_DIR" -maxdepth 1 -name "backup_*.tar.gz" -mtime +$KEEP_DAYS -delete

TOTAL=$(ls "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | wc -l)
FREE=$(df -h "$BACKUP_DIR" | awk 'NR==2 {print $4}')
echo "Total backups: $TOTAL    Free: $FREE"
echo "===== Backup complete $(date) ====="

Replace user with your username everywhere. Make it executable:

chmod

Test it:

~/backup.sh
cat /mnt/backup/backup.log
ls -la

You should see a backup file appear.

Step 5: Schedule daily

crontab -e

Add:

Now backups run automatically at 3 AM every day.

Part 14: Lessons Learned

These are the gotchas that cost me time. Pay attention.

Lesson 1: Executable bit matters

The executable bit on a .mu file tells NomadNet whether to run it as a script or serve it as static text.

  • Static page: chmod 644 file.mu (not executable)

  • Dynamic script: chmod 755 file.mu (executable)

If you make a static file executable, NomadNet tries to run it as a script, fails on the Micron markup, and the page hangs. If you make a dynamic script not executable, NomadNet serves the Python source code as text instead of running it.

How to tell which kind a file is: look at the first line. If it starts with #!/usr/bin/env python3, it's a script (needs +x). If it starts with Micron markup (backticks), it's static (no +x).

Lesson 2: scp sometimes changes permissions

When you scp a file from a Mac to the Pi, the file might arrive with unusual permissions like -rwx--x--x (711) instead of -rwxr-xr-x (755). The 711 mode removes read permissions for everyone except the owner. Your script still runs when you invoke it manually, but NomadNet (which reads the file differently) may fail.

After every scp, double-check:

ls -la

If any file has weird permissions, fix them:

chmod 755 ~/.nomadnetwork/storage/pages/dynamic_script.mu
chmod 644

Lesson 3: Multiple services means multiple restarts

If you have three nodes running (nomadnet, nomadnet_news, nomadnet_warnings), each is a separate service with its own config and pages directory.

When you add or change pages on the news node, you have to restart nomadnet_news, not nomadnet. Restarting the wrong service does nothing useful.

Keep a note of which service owns which config:


Lesson 4: MeshChat caches aggressively

After deploying a new page or updating an existing one, MeshChat may still show the old version. Fixes, in order of increasing force:

  1. Click the refresh icon (circular arrow) in MeshChat's node browser

  2. Navigate home and back into the node

  3. Restart the NomadNet service

Lesson 5: #!c=0 for dynamic pages

NomadNet caches page output by default. For static pages that's fine. For dynamic pages (which generate new content every time), it's a bug. Always include #!c=0 on the second line of dynamic scripts:

#!/usr/bin/env python3
#!c=0

Lesson 6: Chown AFTER mount, not before

When setting up a USB drive mount point:

# WRONG - doesn't work
sudo mkdir /mnt/backup
sudo chown user:user /mnt/backup
sudo mount /dev/sda1 /mnt/backup
# Now /mnt/backup is owned by root again because the drive
# has its own root directory with root ownership

# RIGHT
sudo mkdir /mnt/backup
sudo mount /dev/sda1 /mnt/backup
sudo chown user:user /mnt/backup
# Now you own the mounted filesystem's root

Lesson 7: Form field detection is fragile

Different NomadNet clients (MeshChat, the TUI, mobile apps) pass form fields with slightly different environment variable prefixes. The safe way to read form input is to loop through all environment variables looking for ones that end with your field name:

def recover_input(key_suffix):
    for k, v in os.environ.items():
        if k.lower().endswith(key_suffix):
            return v.strip()
    return ""

message = recover_input("message")

This works across all clients.

Lesson 8: Announce interval trade-offs

Your node announces itself to the network at intervals (default 6 hours, or 360 minutes). Faster announces make you easier to find, but consume more LoRa airtime if you're on a radio link. For testing, 30 minutes is fine. For production radio deployment, consider going back to 360 to conserve airtime.

Lesson 9: Service naming matters

Don't name services generic things like myservice.service. Use names that tell you what they're for:


Six weeks from now you won't remember which is which. Descriptive names save you from restarting the wrong thing.

Lesson 10: Back up early and often

The first thing to do after getting a node working is set up backups. Don't wait. SD cards fail. Power goes out mid-write. You'll accidentally rm -rf something. Backups are cheap insurance.

Lesson 11: Each node has its own cryptographic identity

When you create a new NomadNet instance with --config ~/.nomadnetwork_news, it generates a new identity file. That identity is tied to that config directory.

If you delete the config directory, you lose the identity. If you lose the identity, your node gets a NEW address on the network. Anyone who had favourited your old address can't find you anymore.

This is why backups matter: they preserve identity files, so even after a total system rebuild, your nodes come back with the same addresses.

Lesson 12: Check both tar and ls after every backup

A silent failure where tar runs but produces an empty archive is rare but possible. After running a backup, verify:

ls -la /mnt/backup/backup_*.tar.gz | tail -3
tar -tzf /mnt/backup/backup_latest.tar.gz | head -20

If the file exists, has reasonable size (few MB at minimum), and listing its contents shows expected files, you're good.

Part 15: Where to Go From Here

You now have a working mesh node with pages, a guestbook, RSS feeds, multiple independent instances, and backups. Some directions to explore:

Add a LoRa radio. A LilyGo LoRa32 or Heltec LoRa32 board flashed with RNode firmware plugs into your Pi via USB and adds long-range radio connectivity. Now your node can talk to other Reticulum nodes within several kilometres with no internet required.

Build more node types. Weather station data, local bus schedules, community announcements, a bulletin board. Anything you can express as a cache-and-display pattern works.

Contribute a fork. Projects like LXMF_messageboard, nomadForum, and RetiBBS are all open source. Pick one, add a feature that annoys you about it, submit a pull request.

Set up remote backups. USB backup protects against SD card failure. For true disaster recovery, add a second backup target offsite. A weekly scp to your laptop, or rclone to a cloud provider, survives the physical destruction of the Pi.

Connect it all to a Maker Faire or similar event. Your node is most interesting when strangers interact with it. Bring a screen, a keyboard, and let people leave messages in your guestbook.

Part 16: Handing This Tutorial to Claude

If you're using Claude (or another capable LLM) to help you through this, paste this tutorial as context and then ask questions. Claude can walk you through each step, help troubleshoot when things break, and adapt the tutorial to your specific situation.

Some useful ways to ask:

  • "I'm stuck at Part 6 Step 4 and my service won't start. Here's the output of systemctl status..."

  • "Walk me through Part 11 step by step, pausing after each command so I can confirm it worked."

  • "I want to add a third node for local weather. Adapt Part 11 for that use case."

  • "My guestbook form isn't saving messages. Here's what I see when I run the script manually..."

Paste command output when things go wrong. Paste config files when asking about configuration. The more context the assistant has, the more useful its answers.

Appendix: Quick Reference

Commonly needed commands

Check services:

sudo systemctl status rnsd
sudo systemctl status meshchat
sudo

Restart a service:

sudo systemctl restart

View recent logs for a service:

sudo journalctl -u nomadnet -n 50 --no-pager

Check Reticulum network status:

List cron jobs:

crontab -l

Edit cron jobs:

crontab -e

Check disk usage:

df -h /
du -sh ~/* ~/.[^.]* 2>/dev/null | sort -h | tail -10

File locations summary

Location

Purpose

~/.reticulum/

Reticulum identity and interface config

~/.nomadnetwork/

Main NomadNet node config and pages

~/.nomadnetwork_news/

News NomadNet node config and pages

~/.nomadnetwork/storage/pages/

Where your .mu files live

~/reticulum-meshchat/

MeshChat installation

/etc/systemd/system/*.service

Your systemd service files

/mnt/backup/

USB backup drive mount point

Micron syntax cheat sheet

`c            Center align
`!            Bold
`*            Italic
`F d00        Red foreground
`B fff        White background
``            Reset all formatting

>Heading      Creates a heading
---           Horizontal line

Service file template

[Unit]
Description=NomadNet Instance - NAME_HERE
After=rnsd.service

[Service]
Type=simple
User=USERNAME_HERE
ExecStart=/home/USERNAME_HERE/.local/bin/nomadnet --config /home/USERNAME_HERE/.nomadnetwork_INSTANCE_HERE --daemon
Restart=on-failure
RestartSec=30

[Install]

Replace NAME_HERE, USERNAME_HERE, and INSTANCE_HERE for each new node.

Dynamic page template

#!/usr/bin/env python3
#!c=0

import os, time

# Your logic here

def render():
    print("`c`Bd00`Ffff  NODE HEADER")
    print("``")
    print("")
    print("Your page content here")
    print("")
    print("---")
    print("[Back`:/page/index.mu]")

render()

Save as .mu, chmod 755 to make executable, put in ~/.nomadnetwork_XXX/storage/pages/.

Related posts

April 23, 2026

Adding Emergency Alerts to the Pi, and Finally Setting Up Backups

Description

April 23, 2026

Adding Emergency Alerts to the Pi, and Finally Setting Up Backups

Description

April 23, 2026

Expanding a Reticulum News Node: Feeds, Guestbooks, and the Quiet Tyranny of Permissions

Description

April 23, 2026

Expanding a Reticulum News Node: Feeds, Guestbooks, and the Quiet Tyranny of Permissions

Description

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Got questions?

I’m always excited to collaborate on innovative and exciting projects!

Lisa Zhao, 2025

XX

Lisa Zhao, 2025

XX