On this page
What you are building
A small team needs to talk without handing its messages to a company's servers. The kit is three pieces that trust each other and nothing else: a phone (a GrapheneOS phone running the Element chat app), a laptop you run everything from, and a server the team connects to. You are building the server.
The server is a Raspberry Pi 5, a full computer the size of a deck of cards, kept on all the time. It runs the team's chat and filters ad and tracker traffic for every device. Your people reach it over a private connection from anywhere, with nothing left open to the public internet. The phone and the laptop are clients; the Pi is the server they talk to.
Networks & the internet
A network is a set of devices connected so they can pass data to each other. The devices behind your own router, your laptop, your phone, and the Pi, are your local network, or LAN. Everything outside your router is the internet. The router sits between the two: it is the gateway out, and the boundary that keeps the inside in.
When your laptop talks to the Pi, both are on your LAN, so the traffic goes across your own network and never leaves the house. When your laptop loads a website, the traffic goes out through the router to the internet and back. Keeping the server's traffic on the inside, off the public internet, is most of what "securing it" means.
Addresses & names
Every device on a network has an IP address, a number that says where it is, like 192.168.8.50. Addresses that begin 192.168 (and 10., and 172.16 through 172.31) are private: they work only inside your network. The public internet has no route to a private address, so nothing on the outside can reach your Pi directly by it. The one public address belongs to your router, which speaks to the internet for everything behind it.
People remember names, not numbers, so DNS is the lookup that turns a name into an address. On your own network the Pi answers to the name node.local, so you reach it by name instead of memorizing its number.
Reaching it securely
SSH (Secure Shell) is the encrypted way you operate the server from your laptop's command line. Instead of a password it uses a key pair: a private key that never leaves your laptop, and a public key that sits on the Pi. The two are matched at login, and a key cannot be guessed the way a password can.
A server runs many services at once, so each one listens on a numbered port (SSH listens on port 22). A firewall decides which ports anything outside can reach. Set to default-deny, every port is closed until you open it; you open only what you need, and only to the people you trust.
To let your team reach the server from another city without opening it to the public internet, you put all of your devices on a private VPN, a tunnel only your machines can use. The server is reachable over that tunnel and nowhere else, so there is nothing on the open internet for anyone to find. The limit is worth stating: a VPN controls what is reachable, not who is trusted. The machine that coordinates the tunnel still sits on the internet and has to be hardened too, and any device you add is inside the tunnel with the rest.
Identify your hardware
The server is one Raspberry Pi 5. An NVMe SSD on an M.2 HAT is its disk, a microSD card is the install medium you start from, and a 27W USB-C supply powers it. A GL.iNet travel router is the network, and you set everything up from a laptop.
The laptop is the one piece you reuse across every skill; everything else here is specific to this build. Click each port and chip to see what it does in the build.
Open your terminal
A terminal is a window where you type commands instead of clicking, and the program that reads them is the shell. Every command later in this module runs on the Pi, which is Linux, so once you connect the commands are identical on any laptop. Only opening the terminal and connecting differ.
macOS: open Terminal from Applications, then Utilities (shell is zsh; the SSH client is already installed). Linux: open your terminal app, often Ctrl+Alt+T (shell is usually bash; the SSH client is installed). Windows: open Windows Terminal or PowerShell; Windows 10 and 11 ship the same OpenSSH client, so the connection commands match. You do not need the old PuTTY program.
Run this on any OS; it prints the client version:
$ ssh -VOpenSSH_9.7p1, OpenSSL 3.0.13 30 Jan 2024
# macOS prints a LibreSSL build; Windows prints the same OpenSSH line. exact versions to be captured on the bench.Install the imager
Raspberry Pi Imager is the official program that writes the operating system to the microSD card. It runs on macOS, Windows, and Linux. Download it from the Raspberry Pi site and install it.
Flash the OS
Choose Device: Raspberry Pi 5. Choose OS: Raspberry Pi OS Lite (64-bit), the server build with no desktop. Choose Storage: the microSD card.
Click Next, then Edit Settings. Set the hostname to node. Set a username and password (there is no default pi/raspberry account anymore). Set your wifi name and password, and set Wireless LAN country (without it the wifi will not associate). Set your locale and keyboard. On the Services tab, enable SSH and choose public-key authentication, pasting your laptop's public key (you make that key in Unit 10; if you have one already, paste ~/.ssh/id_ed25519.pub, never the private file). Then write the card.
# step-by-step screenshots of the Device / OS / Storage / Edit Settings screens go here, captured during the build.Assemble & power on
Fit the M.2 HAT and seat the NVMe SSD in it. Insert the microSD card you flashed in Unit 08. Connect an Ethernet cable from the Pi to the GL.iNet router. Connect the 27W USB-C supply last. The Pi 5 boots on its own the moment power reaches it; you do not press anything to start it. It reads the card and configures itself. The small onboard button does a soft shutdown, and powers it back on after one.
Reach it over SSH
Recall from the basics: the router gave the Pi a private address like 192.168.8.50, and it answers to the name node.local, so you reach it by name. SSH uses a key pair: the public key is on the Pi (you put it there when you flashed the card), the private key never leaves your laptop. If you do not have a key yet, make one on the laptop:
$ ssh-keygen -t ed25519 -C "laptop-to-node"Set a passphrase when it asks; that encrypts the private key on disk, so a copied key file is useless without it.
$ ssh [email protected]
The authenticity of host 'node.local (192.168.8.50)' can't be established. ED25519 key fingerprint is SHA256:nThbg6kXU...redacted. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added 'node.local' (ED25519) to the list of known hosts. Linux node 6.12.x-rpi ... # the first connection asks once to trust the Pi's key; after that it is silent. to be captured on the bench.
node $ whoami # your user node $ hostname # node
Before changing anything, update the system. Use full-upgrade, not plain upgrade, so firmware and kernel updates can pull in what they need.
node $ sudo apt update && sudo apt full-upgrade -yWork through the rehearsal below. You connect, bring the Pi current, and set the firewall, typing each real command yourself. It walks the common mistakes, including the firewall step that locks most people out of a remote machine, so you make them here instead of on the Pi. This rehearsal carries through Unit 12, hardening.
Connection refused usually means the Pi has not finished its first boot; wait a minute and retry. Could not resolve hostname node.local means your network is not resolving .local names; find the Pi's address in the router's list of connected devices and use that instead. Permission denied (publickey) means the wrong key is being offered; point at the right one with ssh -i ~/.ssh/id_ed25519 [email protected]. REMOTE HOST IDENTIFICATION HAS CHANGED after a re-flash is fixed with ssh-keygen -R node.local.
Just-enough Linux
Move around: pwd prints where you are, ls lists what is here, cd changes directory. Administer: sudo runs one command as the administrator (root) and asks for your password. Install: apt is the package manager.
node $ sudo apt install -y gitReading package lists... Done
Building dependency tree... Done
The following NEW packages will be installed: git
Setting up git (1:2.x) ...
# a short real session (pwd, ls, cd, the install) to be captured on the bench.Permission denied editing a system file means you forgot sudo. command not found means the tool is not installed yet. Unable to locate package means your package list is stale; run sudo apt update first.
Harden the Pi
Recall from the basics: services listen on numbered ports, and a default-deny firewall closes every port you have not explicitly opened, so a service is never exposed just because it is running.
Put the hardening in a drop-in file so it is not overwritten. Confirm key login works in a second terminal before you do this, and keep your current session open while you test.
node $ sudo nano /etc/ssh/sshd_config.d/99-hardening.confPasswordAuthentication no PermitRootLogin no PubkeyAuthentication yes
node $ sudo sshd -t && sudo systemctl restart ssh # test config, then apply
node $ sudo apt install -y ufwnode $ sudo ufw default deny incoming node $ sudo ufw allow from 192.168.8.0/24 to any port 22 # allow SSH from your LAN, BEFORE enable node $ sudo ufw enable
node $ sudo ufw status verbose Status: active Default: deny (incoming), allow (outgoing) To Action From -- ------ ---- 22/tcp ALLOW IN 192.168.8.0/24 # SSH is open only to your LAN; everything else is denied. to be captured on the bench.
ufw enable lock you out, recovered, then allowed SSH only from your LAN before enabling. Run that part again until it is automatic.
PasswordAuthentication no seems ignored, a cloud-init drop-in in /etc/ssh/sshd_config.d/ is overriding it; check the effective value with sudo sshd -T | grep -i passwordauth. If you run ufw enable before you allow SSH, your session is cut and you need console access to recover. Allow SSH first, every time.
Move to NVMe
The official M.2 HAT+ enables the PCIe lane for you; a third-party HAT may need dtparam=pciex1 added to /boot/firmware/config.txt. Set the Pi to try the SSD first with sudo raspi-config (Advanced Options, Bootloader Version Latest, then Boot Order, NVMe/USB). Then clone the running card to the SSD with the maintained tool:
node $ git clone https://github.com/geerlingguy/rpi-clone node $ cd rpi-clone && sudo cp rpi-clone rpi-clone-setup /usr/local/sbin
node $ lsblk # the NVMe shows up as nvme0n1 node $ sudo rpi-clone nvme0n1
node $ lsblk NAME MAJ:MIN SIZE TYPE MOUNTPOINTS mmcblk0 ... [card] disk # the booted card, with its boot + root partitions nvme0n1 ... [SSD] disk # the empty target, before cloning # exact sizes, partition rows, and mountpoints to be captured on the bench. rpi-clone then copies the hardened card to the SSD and fixes the PARTUUIDs.
Power off, remove the microSD, power back on, SSH in, then:
node $ findmnt / # SOURCE should be /dev/nvme0n1p2, not mmcblk0
dd or the old unmaintained rpi-clone. Re-clone with the geerlingguy fork used above. If lsblk shows no nvme0n1 at all, the HAT ribbon cable is loose or backwards. If the SSD drops out at random, drop it to PCIe Gen 2 (remove any pciex1_gen=3 line) and use the official 27W supply.
Set up the router
Recall from the basics: DNS turns names into addresses. Run the resolver yourself and you control the lookups, refusing known ad and tracker domains for every device on the network at once.
Log in to the router (commonly 192.168.8.1). Change the admin password, set your wifi name and a strong passphrase, and add a guest network for devices you do not trust. Then turn on AdGuard Home (built into GL.iNet): enable it, and enable "AdGuard Home Handle Client Requests" so every device resolves through it. Set the upstream resolver inside AdGuard's own settings, not the router's basic DNS field.
Remote reach uses a self-owned WireGuard VPN coordinated by your own Headscale server on a small VPS, so teammates in other states reach the Pi without any port open to the public internet. On the Pi, join the VPN with the open-source Tailscale client, logged in to your own Headscale rather than a hosted account:
node $ curl -fsSL https://tailscale.com/install.sh | shnode $ sudo tailscale up --login-server https://your-headscale --authkey YOUR_KEY node $ tailscale ip -4 # the 100.x address you bind services to in Unit 15
node $ sudo tailscale up --login-server https://your-headscale --authkey ... Success. node $ tailscale ip -4 100.x.y.z # GL.iNet AdGuard screenshots and the full enrollment go here. to be captured on the bench.
Run the Matrix server
Docker runs each service in its own isolated container so it cannot disturb the rest of the system; Docker Compose defines a service in one file and starts it with one command. Install Docker, then add yourself to the docker group:
node $ curl -fsSL https://get.docker.com | sh node $ sudo usermod -aG docker $USER && newgrp docker
Synapse is the Matrix homeserver. In its compose file you bind it to the Pi's VPN address (the 100.x from Unit 14), not 0.0.0.0, so only VPN peers can reach it. In homeserver.yaml you turn federation off (it talks to no other Matrix servers) and leave registration closed. Then start it and create your account:
node $ docker compose up -dnode $ docker compose up -d [+] Running 2/2 Network synapse_default Created Container synapse Started node $ docker exec -it synapse register_new_matrix_user -c /data/homeserver.yaml -u admin -a New user admin created. # the compose file, the federation-off homeserver.yaml, and the first Element message go here. to be captured on the bench.
Connect the Element client to your homeserver over the VPN, create accounts for your team, and send the first message.
0.0.0.0 instead of the VPN address, the server is reachable from the public internet and "no open ports" is gone. Always bind to the 100.x VPN address. If federation still seems on, check that the listener no longer lists federation in its resources and that the whitelist is empty.
Back it up
Save three things: the Docker compose files and configuration, the Matrix server's data, and the router's exported configuration. Copy them to the SSD and to somewhere off the box on a schedule, then run a restore so you know the copy is good. A backup you have never restored is a guess.
node $ ./backup.sh Saved: compose + config, Synapse data, router export -> /srv/ssd/backups/YYYY-MM-DD.tar.gz Copied off-box -> backup host Restore test: OK # the real backup script and a verified restore go here. to be captured on the bench.
Verify & own it
Check the firewall and the listening services, and confirm the services are bound to the VPN address, not a public one:
node $ sudo ufw status verbosenode $ ss -tlnp # what is listening, and on which address
node $ ss -tlnp State Local Address:Port LISTEN 100.x.y.z:8008 # Synapse, on the VPN address only LISTEN 0.0.0.0:22 # SSH, allowed only from the LAN by ufw # nothing answers on a public address. to be captured on the bench.
The real test is a teammate's phone. On a GrapheneOS phone, install the Tailscale app from F-Droid (no Google account needed) and point it at your own Headscale under Use an alternate server, so the phone joins the same VPN. Then install Element, sign in to your homeserver over the VPN, and send a message. If it arrives, the whole chain holds: a phone in another city reaches a server that has no public address, over a tunnel only your devices share.
# screenshots of Element on GrapheneOS, joined to the VPN, connecting to the homeserver and sending the first message, go here. to be captured on the bench.Optional reference · how networks work underneath
Build methodology and the optional full-rack upgrade are adapted from the M2 Community Node (CC BY-NC-SA 4.0).
Glossary
- AdGuard Home
- A network-wide DNS filter that blocks ad and tracker domains for every device behind the router.
- apt
- The package manager on Raspberry Pi OS; installs and updates software (
sudo apt install). - client
- A device that connects to a server to use its services, like your laptop or phone.
- cloud-init
- The system that configures the Pi on its first boot from the settings you wrote in the imager.
- container
- An isolated box that runs one service without disturbing the rest of the system. Run by Docker.
- default-deny
- A firewall policy that blocks every port until you explicitly open it.
- DNS
- The lookup that turns a name (
node.local) into an IP address. - Docker
- Software that runs each service in its own container. Docker Compose defines and starts services from one file.
- Element
- The Matrix chat app the team uses, on the phone and the laptop.
- federation
- Matrix servers talking to other Matrix servers. Turned off here so the server stays private.
- firewall
- The gate that decides which ports anything outside can reach. Set with
ufw. - gateway
- The router: the single door between your network and the internet.
- GrapheneOS
- A hardened, de-Googled version of Android the team's phones run.
- Headscale
- The self-hosted coordinator for your WireGuard VPN. You run it, not a company.
- headless
- Running a computer with no monitor or keyboard, reached over the network instead.
- hostname
- A machine's name on the network (
node), used asnode.local. - IP address
- The number that says where a device is on a network, like
192.168.8.50. - key pair
- The two halves of an SSH key: a private key that stays on your laptop, a public key on the Pi.
- LAN
- Your local network: the devices behind your own router.
- LUKS
- Full-disk encryption that makes the disk unreadable if the SSD is removed.
- Matrix
- The open protocol for the chat. Synapse is the server, Element is the app.
- microSD
- The card you install from. Slower than the SSD and not the long-term disk.
- NVMe SSD
- The fast, durable disk the finished server runs from.
- node.local
- The name your Pi answers to on your own network.
- port
- A numbered door a service listens on (SSH on 22).
- private address
- An address (
192.168.x,10.x,172.16–31) that works only inside your network and is unreachable from the internet. - public address
- A routable address reachable from anywhere. The router holds the single one for your network.
- Raspberry Pi 5
- The small, always-on computer that is the server.
- router
- The device that joins your network to the internet and hands out addresses.
- server
- A computer kept on to run services other devices use. Here, the Pi.
- SSH
- Secure Shell: the encrypted command-line connection you run the server over.
- sudo
- Runs one command as the administrator (root).
- Synapse
- The Matrix homeserver software you run in Docker.
- ufw
- The Uncomplicated Firewall: the tool you set the firewall with.
- VPN
- A private tunnel only your devices use, so the team reaches the server with nothing exposed to the internet.
- WireGuard
- The VPN protocol underneath: fast, built into the Linux kernel, key-based.