When planning raspberry pi server projects, the gap between a weekend toy and a reliable homelab appliance usually comes down to three things: storage I/O, power resilience, and automated fault handling. Booting off a microSD card is a guaranteed way to corrupt your filesystem within six months of running Docker containers. Running without a UPS guarantees data loss during the first summer thunderstorm.

This guide walks through building a production-grade Raspberry Pi 5 NVMe Docker server with an integrated I2C UPS HAT. We will cover the hardware decision matrix, exact pin mappings, and provide a complete Python daemon to monitor battery voltage and trigger a graceful shutdown before the cells drain.

The 2026 Decision Matrix: Which Pi for Your Server?

Do not default to the most expensive board. Match the silicon to the workload. Here is the decision path for current-generation Raspberry Pi server projects:

Workload Profile Recommended Board RAM Storage Interface Typical Power Draw
Pi-hole, MQTT Broker, basic cron jobs Pi Zero 2 W 512MB microSD / USB 2.0 1.5W - 2.5W
Nextcloud, Home Assistant, light media Pi 4 Model B 4GB USB 3.0 SSD 4W - 8W
Docker stacks, Jellyfin, ZFS, CI/CD runners Pi 5 8GB PCIe 2.0 NVMe 12W - 25W
The Concrete Pick: If you are building a general-purpose homelab server in 2026 to run Docker containers and a NAS, buy the Raspberry Pi 5 (8GB). The PCIe lane unlocks NVMe speeds (up to 400MB/s on the Pi 5's PCIe 2.0 x1 interface), which eliminates the I/O bottlenecks that choked Pi 4 Docker builds.

Parts List & Hardware Spec Sheet

This build targets the Raspberry Pi 5 8GB variant running Raspberry Pi OS (Bookworm 64-bit). Here is the exact bill of materials, priced at typical 2026 retail:

  • Compute: Raspberry Pi 5 (8GB) — ~$80
  • Power/UPS: Geekworm X735 V3.0 UPS HAT (with 18650 battery holder) — ~$35
  • Batteries: 2x Samsung INR18650-35E (3500mAh, unprotected button-top) — ~$14
  • Storage: Samsung 990 EVO 1TB NVMe M.2 SSD — ~$90
  • NVMe Adapter: Geekworm X1001 PCIe to NVMe HAT — ~$15
  • Enclosure: Argon ONE V3 Pi 5 Case (integrates NVMe and active cooling) — ~$30
  • Power Supply: Official Raspberry Pi 27W USB-C PD Power Supply — ~$12
Lithium Safety Note: Never parallel mismatched 18650 cells in the X735 HAT. Use identical cells from the same batch. The X735 includes a basic DW01A protection IC, but it is not a substitute for a full BMS. Charge only via the HAT's dedicated USB-C power input, not by backfeeding the Pi's USB-C port.

Assembly & Pin Mapping: Wiring the UPS HAT

The X735 communicates battery voltage and capacity via the I2C bus, and uses a dedicated GPIO pin to signal the Pi to shut down when the physical button is pressed. The X1001 NVMe HAT sits on the bottom of the Pi 5, while the X735 sits on top.

GPIO & I2C Pin Mapping (BCM Numbering)

Function BCM GPIO Physical Pin Protocol/Notes
I2C SDA GPIO 2 Pin 3 Battery data (Address 0x36)
I2C SCL GPIO 3 Pin 5 Battery clock
Shutdown Signal GPIO 4 Pin 7 Pulled high, drops low on button press
Power Status GPIO 17 Pin 11 High = External Power, Low = Battery

Numbered Assembly Steps:

  1. Flash Raspberry Pi OS (64-bit, Bookworm) to a temporary microSD card using Raspberry Pi Imager. Enable SSH and set your WiFi/locale in the OS customization menu.
  2. Boot the Pi 5, open a terminal, and update the EEPROM to ensure NVMe boot support: sudo rpi-eeprom-update -a. Reboot.
  3. Mount the X1001 NVMe HAT to the bottom of the Pi 5 using the provided FPC (Flexible Printed Circuit) PCIe cable. Ensure the gold contacts are fully seated and the blue tab faces the correct direction.
  4. Clone the NVMe drive to the SSD using a USB NVMe enclosure and dd, or simply flash the OS directly to the NVMe via a PC. Remove the microSD card.
  5. Stack the X735 UPS HAT on top of the Pi 5 GPIO header. Secure with the provided M2.5 standoffs.
  6. Insert the 18650 batteries into the X735 holder, observing correct polarity. Connect the 27W USB-C PD supply to the X735's power input, not the Pi 5's native port.

The Code: Graceful Shutdown Monitor (Python)

This Python 3 script targets the Raspberry Pi 5 (Bookworm). It uses the smbus2 library to read the MAX17043 fuel gauge IC on the X735 HAT (I2C address 0x36). If the battery drops below 15% during a mains outage, it triggers a safe shutdown to prevent filesystem corruption.

First, install dependencies and enable I2C:

sudo raspi-config nonint do_i2c 0
sudo apt update && sudo apt install python3-smbus python3-gpiozero -y

Create the daemon script at /opt/ups_monitor/ups_daemon.py:

#!/usr/bin/env python3
"""
UPS I2C Monitor for Geekworm X735 on Raspberry Pi 5.
Reads MAX17043 fuel gauge and triggers shutdown on low battery.
"""
import smbus2
import time
import os
import logging
from gpiozero import Button

# Hardware Constants
I2C_BUS = 1
MAX17043_ADDR = 0x36
VCELL_REG = 0x02
SOC_REG = 0x04
SHUTDOWN_GPIO = 4  # BCM 4 / Physical Pin 7
LOW_BATTERY_THRESHOLD = 15.0  # Percentage

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    filename='/var/log/ups_monitor.log'
)

bus = smbus2.SMBus(I2C_BUS)
shutdown_button = Button(SHUTDOWN_GPIO, pull_up=True, bounce_time=0.5)

def read_voltage():
    """Reads VCELL register and converts to volts."""
    try:
        raw = bus.read_word_data(MAX17043_ADDR, VCELL_REG)
        # Swap bytes (MAX17043 is big-endian, smbus reads little-endian)
        raw = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF)
        return (raw * 1.25) / 1000.0
    except OSError as e:
        logging.error(f'I2C Read Error (Voltage): {e}')
        return None

def read_capacity():
    """Reads SOC (State of Charge) register."""
    try:
        raw = bus.read_word_data(MAX17043_ADDR, SOC_REG)
        raw = ((raw & 0xFF) << 8) | ((raw >> 8) & 0xFF)
        return raw / 256.0
    except OSError as e:
        logging.error(f'I2C Read Error (Capacity): {e}')
        return None

def handle_physical_button():
    logging.info('Physical shutdown button pressed. Halting system.')
    os.system('sudo shutdown -h now')

def main():
    logging.info('UPS Daemon started on Pi 5.')
    shutdown_button.when_pressed = handle_physical_button

    while True:
        voltage = read_voltage()
        capacity = read_capacity()

        if capacity is not None and voltage is not None:
            logging.debug(f'Batt: {voltage:.2f}V | Cap: {capacity:.1f}%')
            if capacity < LOW_BATTERY_THRESHOLD:
                logging.warning(f'Critical battery ({capacity:.1f}%). Shutting down!')
                os.system('sudo shutdown -h now')
                break
        else:
            logging.warning('Failed to read I2C sensors. Check HAT connection.')

        time.sleep(30)  # Poll every 30 seconds

if __name__ == '__main__':
    main()

To ensure this runs on boot, create a systemd service at /etc/systemd/system/ups-monitor.service:

[Unit]
Description=UPS I2C Monitor Daemon
After=multi-user.target

[Service]
ExecStart=/usr/bin/python3 /opt/ups_monitor/ups_daemon.py
Restart=on-failure
User=root

[Install]
WantedBy=multi-user.target

Enable it with sudo systemctl enable --now ups-monitor.service.

Debugging: "OSError: [Errno 121] Remote I/O error"

When working with I2C HATs on Raspberry Pi server projects, you will inevitably encounter this exact error string in your logs:

OSError: [Errno 121] Remote I/O error

This means the Pi's I2C controller sent a clock pulse, but the MAX17043 chip on the UPS HAT did not acknowledge (ACK) the transaction. Here are the first three things to check, ranked by likelihood:

  1. I2C Interface is Disabled (90% of cases): Bookworm OS defaults to disabling I2C. Run sudo raspi-config, navigate to Interface Options > I2C, and enable it. Verify with lsmod | grep i2c and i2cdetect -y 1. You should see 36 in the grid.
  2. Loose FPC Cable or HAT Header (8% of cases): The 40-pin header on the X735 might not be fully seated. Power down, remove the HAT, inspect the pins for bent contacts, and reseat firmly. If using a ribbon cable extension, check for micro-tears.
  3. Pull-up Resistor Failure / Bus Lockup (2% of cases): If the Pi crashed hard previously, the I2C bus might be locked in a low state. Measure the voltage on Pin 3 (SDA) and Pin 5 (SCL) with a multimeter. Both should read ~3.3V relative to Pin 6 (GND). If either reads 0V, power cycle the Pi and the UPS HAT completely to reset the bus logic.

Extending or Simplifying the Build

Not every deployment requires a full NVMe and UPS stack. Here is how to adjust this architecture based on your constraints:

How to Simplify (Budget / Low Power)

  • Drop the NVMe: Boot from a high-endurance microSD card (like SanDisk High Endurance 64GB, ~$12). Move your Docker volumes to a standard USB 3.0 SATA SSD to save the cost of the X1001 HAT and NVMe drive.
  • Drop the UPS HAT: If your Pi is behind a whole-home UPS (like a CyberPower CP1500PFCLCD), you don't need the X735. Simply connect the Pi to the UPS's USB-B port and install nut-server (Network UPS Tools) to monitor the main UPS battery over USB.

How to Extend (High Availability / Storage)

  • Add ZFS Mirroring: The Pi 5's PCIe lane can be split using an ASM1182e PCIe switch board, allowing you to attach two NVMe drives. Format them with OpenZFS in a mirror configuration for hardware-level redundancy.
  • Cluster with Docker Swarm: Build three identical Pi 5 nodes. Initialize Swarm on the primary (docker swarm init) and join the others as workers. Deploy Portainer to manage your stacks across the cluster, ensuring that if one Pi loses power, your containers migrate to the surviving nodes.

For authoritative details on Pi 5 PCIe boot configurations, refer to the official Raspberry Pi PCIe documentation. For specific I2C registers and HAT wiring, consult the Geekworm X735 Wiki.