Configuring Ubuntu Server on a Raspberry Pi
I have a standard base configuration for my Raspberry Pi Ubuntu servers. These are the changes I make from the standard install.
Once Ubuntu Server is installed and reachable on the network, a stock image still needs a fair bit of shaping before it is a machine you are happy to leave running in a cupboard and forget about. This is the pass I make on every one: firmware tuning, networking that stays put, a clock that survives power cuts, automatic security updates, and the handful of services almost every box ends up wanting.
You should be able to SSH in as the account you created, the Pi should have internet access, and you should be comfortable editing files with nano or vi and using sudo.
Basics
Give root a password — you will rarely use it, but you want one for console or recovery access, so put it in a password manager — and take the system fully up to date:
sudo passwd root
sudo apt update && sudo apt full-upgrade -y
full-upgrade rather than plain upgrade so that apt is allowed to add or remove packages when an update needs it, which matters around kernel changes.
Firmware config (config.txt)
/boot/firmware/config.txt is read by the Pi’s bootloader before Linux starts, so it is where low-level hardware settings live. It is divided into conditional sections — [all] applies everywhere, [pi4] and [pi5] only on that model — so you can keep one file that works across a mixed set of machines. Edit it with sudo nano /boot/firmware/config.txt.
Two changes make sense on any headless box. There is no display, so hand the RAM the GPU would otherwise reserve back to the system with gpu_mem=16 (this has less effect on a Pi 5, which manages graphics memory dynamically, but it does no harm). And if the machine is wired-only, turn off the radios in firmware: it saves a little power, removes an attack surface you are not using, and avoids the occasional confusion of a second network path appearing. Do it here rather than with nmcli, because the tool that command talks to is not running on this machine.
[all]
gpu_mem=16
dtoverlay=disable-wifi
dtoverlay=disable-bt
[pi4]
arm_boost=1
arm_boost=1 lets a Pi 4 run its CPU at the higher of its two rated clocks; it costs a little heat, which is what the fan is for. Recent firmware turns this on by default — setting it explicitly just makes the intent obvious and covers an older bootloader.
Fan control
When a Pi gets hot it throttles its own CPU to protect itself, so a fan is really about holding performance under sustained load rather than stopping the board melting. The Pi supports a few different fan arrangements and each is configured differently. Add one of the blocks below — whichever matches the cooling you actually have. Every temperature is in thousandths of a degree Celsius, so 50000 means 50 °C.
Pi 4 with the PoE HAT, which has a small fan built in. You give it four temperature steps and the fan gets progressively louder at each one:
[pi4]
dtoverlay=rpi-poe
dtparam=poe_fan_temp0=50000 # fan switches on
dtparam=poe_fan_temp1=58000 # speeds up
dtparam=poe_fan_temp2=64000 # speeds up again
dtparam=poe_fan_temp3=68000 # full speed
Pi 5 with the PoE+ HAT — exactly the same idea, just a different overlay name for the newer HAT:
[pi5]
dtoverlay=rpi-poe-plus
dtparam=poe_fan_temp0=50000
dtparam=poe_fan_temp1=58000
dtparam=poe_fan_temp2=64000
dtparam=poe_fan_temp3=68000
Pi 5 with the official Active Cooler, or a case fan plugged into the 4-pin fan header. The Pi 5 firmware already runs this fan on a sensible default curve, so you only need this block if you want to change it. Each step takes three parameters:
fan_tempN– the temperature that brings in step Nfan_tempN_hyst– how far the temperature has to fall back below that before the fan drops down a step again, which stops it hunting on and off around a thresholdfan_tempN_speed– the PWM speed at that step, from0(off) to255(full)
[pi5]
dtparam=fan_temp0=60000
dtparam=fan_temp0_hyst=5000
dtparam=fan_temp0_speed=75
dtparam=fan_temp1=65000
dtparam=fan_temp1_hyst=5000
dtparam=fan_temp1_speed=128
dtparam=fan_temp2=70000
dtparam=fan_temp2_hyst=5000
dtparam=fan_temp2_speed=192
dtparam=fan_temp3=80000
dtparam=fan_temp3_hyst=5000
dtparam=fan_temp3_speed=255
If you carried an older dtoverlay=cooling-fan,temp0=... line over from a previous setup, delete it — the dtparam=fan_temp* form above is what replaced it.
A plain two-wire fan wired to a GPIO pin through a transistor. This is simple on/off with no speed control — one overlay line, one threshold:
[all]
dtoverlay=gpio-fan,gpiopin=14,temp=70000 # on at 70 C, off once it drops back
Pi 5 NVMe boot
If a Pi 5 boots from an NVMe SSD on the PCIe connector, also enable the interface and, if the drive supports it, the faster PCIe generation:
[pi5]
dtparam=nvme
dtparam=pciex1_gen=3 # drop to =2 if a cheap drive proves unstable
Reboot after editing so the bootloader picks the changes up: sudo reboot.
Networking
Ubuntu Server describes its networking in Netplan — one or more YAML files under /etc/netplan/ — and renders that description down to the live configuration for you. Treat those YAML files as the only place the network is defined. If you also start hand-editing the generated files underneath, the two drift apart and you end up debugging a config that is not the one actually in effect.
Before anything else, find out what your wired interface is actually called. Run:
ip -br link
You will see lo (ignore it) and one or more real interfaces. On a Raspberry Pi running Ubuntu it is usually eth0; on other hardware, or with predictable network names in play, it may be something like enp1s0 or end0. Pick the one that is wired and shows UP when the cable is in. Whatever it is called, use that name everywhere the examples below say eth0.
With that in hand, two things are worth changing from the defaults.
Fix the DNS servers
Out of the box the Pi uses whatever DNS servers your router hands out over DHCP. For a server you usually want that to be predictable instead — your own DNS server, or a fixed pair of public ones — so name resolution does not quietly change under you, or break for a minute every time the router reboots.
First stop cloud-init from regenerating the network config on every boot, and clear out any file that would compete with the one you are about to write:
echo "network: {config: disabled}" | sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
sudo rm -f /etc/netplan/50-cloud-init.yaml /etc/systemd/network/*.network
Then write a single file, /etc/netplan/01-netcfg.yaml:
network:
version: 2
ethernets:
eth0: # check the real name with: ip link
dhcp4: true
dhcp4-overrides:
use-dns: false
dhcp6: true
dhcp6-overrides:
use-dns: false
nameservers:
addresses: [192.168.1.2, 192.168.1.3]
search: [home.arpa]
The machine still takes its address from DHCP — use-dns: false just tells it to ignore the DNS servers that come with that lease and use the ones you listed instead. search is the domain appended to bare hostnames, so ssh otherbox becomes ssh otherbox.home.arpa. Tighten the file permissions (Netplan warns if the file is world-readable) and apply it:
sudo chmod 600 /etc/netplan/*.yaml
sudo netplan try
Use netplan try rather than netplan apply when you are doing this over SSH. It applies the new config and then waits: press Enter to keep it, or do nothing and after 120 seconds it rolls the change back to exactly what you had. If a mistake kills your connection — wrong interface name, a typo in the YAML — the rollback saves you a trip to plug in a keyboard. Once you press Enter to confirm, the config is live and the file is already on disk, so it survives reboots; there is nothing else to run.
Check the result with resolvectl status (your DNS servers listed against the interface, not the router’s) and ip route show default (a sensible gateway).
Stop “waiting for the network” from stalling the boot
There is a service, systemd-networkd-wait-online, whose job is to hold up the rest of the boot until the network is considered ready — useful if something on the machine genuinely needs the network before it can start, such as a remote filesystem mount. On a network that takes its time to settle — a mesh, a big stack of switches, or anything coming back after a power cut — that service can add minutes to every boot, or give up and mark itself failed even though networking is actually fine.
If nothing on this box truly needs the network that early, the simplest answer is to switch the wait off:
sudo systemctl disable --now systemd-networkd-wait-online.service
If you do need it, give it a generous ceiling instead so a slow network delays the boot but never hangs it:
sudo systemctl edit systemd-networkd-wait-online
[Service]
ExecStart=
ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --timeout=300 --interface=eth0
Keeping time
A Pi 4 has no battery-backed real-time clock, so when it powers on it has no idea what time it is until the network is up and it can ask an NTP server. (A Pi 5 does have a real-time clock, with a header for a coin-cell to keep it running while unplugged — the packages below still do no harm, and fake-hwclock covers you if there is no battery fitted.) Two packages cover the gap:
sudo apt install chrony fake-hwclock
chrony is the NTP client that disciplines the clock once the network is available — it is a better fit for a machine that is not always online than the default timesyncd. fake-hwclock writes the current time to a file once an hour, and restores it at boot, so that in the window before NTP catches up the clock is at least roughly right rather than back in 1970 — which matters for logs and TLS certificate checks.
If you run a time server you trust on your own network — a NAS or the router — add it near the top of /etc/chrony/chrony.conf. Leave the existing pool lines in place below it as a fallback; the local server is lower latency and keeps working if your internet connection is down, but the pools cover you if the local one is unavailable:
server 192.168.1.2 iburst prefer
sudo systemctl restart chrony
Automatic security updates
The unattended-upgrades package applies updates on its own so a machine you are not watching does not quietly fall behind on security fixes. It is installed by default; it just needs its policy adjusted. In /etc/apt/apt.conf.d/50unattended-upgrades, find the Allowed-Origins block near the top and uncomment this line so it also takes ordinary bug-fix updates, not only security ones:
"${distro_id}:${distro_codename}-updates";
Then, further down the same file, set:
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "02:00";
The two Remove-* lines stop old kernels and orphaned packages piling up until /boot is full — a genuinely common way for a Pi to wedge itself. The reboot lines let it restart itself, at a quiet hour, when an update needs it (a new kernel, mostly). If a machine wrongly believes it is on battery power and so refuses to run upgrades, add Unattended-Upgrade::OnlyOnACPower "false";.
Then in /etc/apt/apt.conf.d/20auto-upgrades, have it fetch upgradeable packages in the background and tidy the cache periodically:
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "30";
Check the configuration parses and see what it would do with sudo unattended-upgrades --dry-run --debug.
Shell and tools
sudo apt install zsh htop tmux byobu vim tree bmon nmap
Roughly: htop is a readable process monitor, tmux and its friendlier wrapper byobu keep a session alive when your SSH connection drops, vim replaces the cut-down vim-tiny that ships by default, tree prints directory structure, bmon shows live per-interface bandwidth, and nmap is for the times you need to work out what is actually listening on the network.
Server-class hardware has a management controller (IPMI); a Pi does not. If sudo dmidecode -t 38 reports nothing, the matching service has nothing to do, so switch it off so it is not sitting there in a failed state:
sudo systemctl disable --now openipmi
Quieten the login banner, which by default carries help text and an advertising “news” line:
sudo chmod a-x /etc/update-motd.d/10-help-text
sudo sed -i 's/^ENABLED=1/ENABLED=0/' /etc/default/motd-news
Logged in as your normal user (not root, and not with sudo — you want the key in your own home directory), generate an SSH key pair:
ssh-keygen -t ed25519
You will want one the first time this machine has to log in to another — copying a file across, cloning a Git repository, running a backup to a NAS — and it is easier to have it sitting there than to remember to make one halfway through some other task. Accept the default location, and give it a passphrase unless it will be used unattended by scripts. If root also needs to reach other machines directly, run the same command again in a root shell.
If zsh is your preferred shell, Oh My Zsh is a comfortable way to set it up, with sensible defaults and a plugin system:
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
A reasonable set of plugins to enable in ~/.zshrc:
plugins=(z git history history-substring-search common-aliases zsh-syntax-highlighting docker docker-compose)
If you keep an eye on your machines with Prometheus — worth doing past a handful of hosts — install the node exporter, which publishes CPU, memory, disk and network metrics for it to scrape:
sudo apt install prometheus-node-exporter
It listens on port 9100 with no authentication, so treat it as something for a trusted network only. (Docker can expose its own metrics the same way, via a metrics-addr line in the daemon config below — same caveat applies.)
raspi-config
sudo apt install raspi-config
The Raspberry Pi’s own settings tool. The performance options are already handled by the config.txt edits above, but it is handy to have around for the occasional interface toggle later.
Docker
Almost everything I run on these machines runs in a container, so Docker goes on early. Use Docker’s own APT repository rather than the docker.io package in Ubuntu, which tends to lag well behind.
The commands below are straight from Docker’s Ubuntu install guide — on Raspberry Pi OS use the Debian page instead, and if you are reading this a while after it was written, check the guide for the current version. As of now, first add Docker’s signing key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Then register the repository:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
And install the engine and the two plugins worth having:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker-compose-plugin gives you the modern docker compose command; the old standalone docker-compose (with a hyphen) is end-of-life, so only add it if you have scripts that still call it by that name.
Add your account to the docker group so you can drive it without sudo:
sudo usermod -aG docker "$USER"
Log out and back in for that to take effect. Worth knowing: being in this group is as good as being root — anyone in it can start a container that mounts the whole machine — so only add people you would hand root to anyway.
One last piece of config. By default Docker keeps a container’s log output forever, and a chatty container can slowly fill the disk that way, so cap it. Create /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" }
}
sudo systemctl restart docker
Check it came up. The service is enabled on boot by the package, so you should not need to do anything else:
docker compose version
sudo docker run --rm hello-world
File sharing (Samba)
To hand files to Windows and Mac machines, install Samba, which serves the SMB protocol both of them speak. (Modern macOS dropped AFP years ago, so there is no longer any reason to install netatalk.)
sudo apt install samba avahi-daemon
In the [global] section of /etc/samba/smb.conf, add the options that make Samba behave well for macOS clients — correct handling of resource forks, Finder metadata and rename semantics:
min protocol = SMB2
vfs objects = catia fruit streams_xattr
fruit:metadata = stream
fruit:model = MacSamba
fruit:posix_rename = yes
fruit:veto_appledouble = no
fruit:wipe_intentionally_left_blank_rfork = yes
fruit:delete_empty_adfiles = yes
multicast dns register = no
Then share each user’s home directory by filling in the [homes] section:
[homes]
comment = Home Directories
browseable = no
read only = no
create mask = 0700
directory mask = 0700
valid users = %S
Samba keeps its own password database separate from the system one, so set an SMB password for your account:
sudo smbpasswd -a <username>
To make the share appear in Finder’s sidebar without anyone typing an address, advertise it over Bonjour. In /etc/avahi/avahi-daemon.conf, under the [publish] section, set publish-workstation=yes (it defaults to no); enable-wide-area is already on by default, so you should not need to touch it. Then create /etc/avahi/services/smb.service:
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_smb._tcp</type>
<port>445</port>
</service>
</service-group>
Before restarting anything, run testparm. It parses smb.conf, prints the configuration Samba will actually use, and points at the line number of any syntax mistake — much nicer than finding out from a service that will not start.
Then the last piece. nmbd, the old NetBIOS name-service half of Samba, is not needed by anything current and spends its time trying to win an election to be the network’s “master browser”. Turn it off and restart the parts you are keeping:
sudo systemctl disable --now nmbd
sudo systemctl restart smbd avahi-daemon
Reboot and check
A lot of this only really proves itself across a reboot — the firmware changes, the radios going away, netplan coming up clean, every service starting in the right order. So reboot once now:
sudo reboot
When it comes back, a quick pass to confirm nothing is broken:
systemctl --failed # should list nothing
resolvectl status # your DNS servers, not the router's
ip route show default # a sensible gateway
timedatectl # clock synchronised
docker run --rm hello-world # container runtime works
And from another machine on the network, open the file share — on a Mac it should show up in Finder’s sidebar, on Windows as \\hostname. If all of that checks out, you have a solid base. Setting up outbound email and the container stack itself are jobs for another post.