2040 words · 10 min read

Table of contents
Raspberry Pi Zero with 4G hat

What if you could run a blog on 1W of power, served over 4G from a solar-charged Raspberry Pi Zero?

As software engineers, we spend most of our time in a world of virtually infinite cloud resources, where hard limits have been replaced by elastic bills and layers of abstraction (IaaS, PaaS, SaaS) that keep us far from the hardware. It’s easy to lose track of what’s possible with very little.

This is an experiment in doing more with less. Join me on this journey.

This is Part 1 of a 2-part series. Part 1 (this article) covers building and serving the blog over 4G using a Cloudflare Tunnel. Part 2 goes further off-grid: powering the whole setup with a solar panel and a battery hat, keeping it running through the night, setting up the GPS module on the hat, and a few tips to squeeze every milliwatt out of the Pi.

Requirements

Prerequisites before starting:

  • A domain name added to Cloudflare (free plan) — see Cloudflare docs
  • An SSH key pair on your machine (~/.ssh/id_rsa)
  • Throughout this tutorial, replace <user> with your chosen username (e.g. thylong) and <hostname> with your chosen hostname (e.g. solar)

Hardware

ComponentCosts
Raspberry Pi Zero 2 W (the Zero 2 W is recommended — it supports both 32-bit and 64-bit OS; the original Zero W is 32-bit only)18€
Waveshare SIM7600G-H 4G hat75€
Micro-SD card (min. 8Go) + MicroSD to SD adapter (to flash from a laptop)5€
10W 5V Solar panel USB-C25€
Pi Sugar 3 Plus battery hat 1200 mAh — setup covered in Part 234€
Nano SIM with mobile subscriptionStarting at 1€/month
Total150€ + 1€/month
Assembled Raspberry Pi Zero with 4G hat

Notes: Many alternatives to this 4G hat are possible for tighter budgets, same for the battery.

Software

ComponentCosts
Domain setup on Cloudflare~
Cloudflare tunnelFree

Steps

Hardware setup

  • Turn off the Raspberry
  • Plug the hat on top of the Raspberry Pi 0, it should look like the following:
4G hat mounted on Raspberry Pi Zero
  • Insert a nano SIM in the 4G hat

Software setup

Install the OS

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

Flash Raspberry Pi OS Lite (32-bit for Zero W, 64-bit for Zero 2 W) to your MicroSD card.

In the imager’s Advanced Options (the gear icon), before writing:

  • Set the hostname (e.g. solar) — this is how you’ll SSH in as ssh <user>@solar
  • Set your username and password (e.g. thylong)
  • Enable SSH with your public key
  • Add your WiFi credentials — the Pi needs internet access on first boot to activate Raspberry Pi Connect

Note: If you don’t have a keyboard or display to connect to your Raspberry, ensure that you provide valid WiFi credentials and turn on Raspberry Pi Connect so you can use the screen sharing.

It can take a few minutes for the Raspberry to connect to the service. If after 5 minutes the device still doesn’t appear, it probably didn’t connect to the WiFi and/or internet successfully. You will have to redo the SD card flashing phase.

Common errors: a wrong WiFi SSID, password typo, or invalid auth key.

Configure the 4G/LTE hat

Boot the Raspberry Pi.

Ensure the hat is properly wired

Make sure the LEDs on the hat are:

  • PWR light is on
  • NET light blink pattern:
    • Slow blink (1s on / 1s off) = searching for network
    • Fast blink (100ms) = registered and connected ✓

If the NET light is off or blinking slowly after 30 seconds, the hat isn’t properly connected physically to the Raspberry Pi Zero or the SIM isn’t recognized.

SIM7600 4G Hat top and bottom

Ensure hat connectivity from the Raspberry

Connect to Raspberry Pi Connect

Install required packages:

sudo apt update && sudo apt install -y minicom modemmanager network-manager

Open a terminal and ensure both these lines are present in the /boot/firmware/config.txt:

enable_uart=1
dtoverlay=disable-bt

These two lines free up the UART pins on the GPIO header, which the modem uses for serial communication. Without them, the hat may not be detected correctly.

If you make any change, reboot to apply (sudo reboot).

Then run ls /dev/ttyU* to list ttys, the Raspberry should have detected via USB the hat and output:

/dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2 /dev/ttyUSB3 /dev/ttyUSB4

Here is a simple mapping of the ttys:

PortFunction
/dev/ttyUSB0Diagnostic
/dev/ttyUSB1GPS/NMEA
/dev/ttyUSB2AT commands ← use this
/dev/ttyUSB3PPP / data
/dev/ttyUSB4Audio/other

You can test the communication via the AT Command port:

sudo minicom -D /dev/ttyUSB2 -b 115200

Then:

  1. Press Ctrl+A then E to enable echo
    1. Type AT + Enter
    2. → should return OK
  2. Then verify the SIM:
    1. Type AT+CPIN?
    2. → should return +CPIN: READY
  3. Then verify the network
    1. Type AT+COPS?
    2. → should show Orange details (if you’re using Orange multi-SIM)

Other AT Commands for Testing for the curious:

AT CommandDescriptionReturn
ATAT Test CommandOK
ATEATE1: Echo mode on, ATE0: Echo mode offOK
AT+CGMIRequest manufacturer identificationOK
AT+CGMMRequest model identificationOK
AT+CGSNRequest product serial number identificationOK
AT+CSUBRequest product versionOK
AT+CGMRRequest firmware versionOK
AT+IPREXConfigure baud rate of model+IPREX:OK
AT+CRESETReset modelOK
AT+CSQQuery signal qualityOK
AT+CPIN?Query SIM card status+CPIN:READY
AT+COPS?Query provider information+COPS:OK
AT+CREG?Query network registration status+CREG: OK
AT+CPSI?Query UE system information
AT+CNMPSelect Network ModeOK

If you made it to this point, congratulations — the modem is ready for us to create connections!

If you’re facing any errors or not having the expected output when using minicom, check the output of mmcli -L, it will help debugging.

To create the connection, run the following commands:

We use APN orange (not orange.fr). The orange.fr APN is used for tethering and disables IPv6 — we need orange to get a public IPv6 address later.

sudo nmcli connection add type gsm ifname '*' con-name orange-4g apn orange
sudo nmcli connection up orange-4g
sudo nmcli connection modify orange-4g connection.autoconnect yes

To test the connection, use curl:

curl --interface wwan0 https://ifconfig.me

If you see your public IP, congratulations, you’re connected to 4G!

Enable IPv6 and make it persistent

Orange’s 4G blocks inbound connections on both IPv4 (CGNAT) and IPv6 (firewall). However, Cloudflare Tunnel needs to connect outbound on port 7844 — Orange blocks this on IPv4 but allows it on IPv6. We therefore need to configure dual-stack IPv4v6 and make it persist across reboots.

# Set initial EPS bearer to dual-stack
sudo mmcli -m 0 --3gpp-set-initial-eps-bearer-settings="apn=orange,ip-type=ipv4v6"

# Reconnect with dual-stack
sudo mmcli -m 0 --simple-disconnect && sleep 3 && sudo mmcli -m 0 --simple-connect="apn=orange,ip-type=ipv4v6"

# Read IPv6 config from bearer and apply to wwan0
BEARER=$(mmcli -m 0 --output-json | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | tail -1)
IPV6=$(mmcli -b ${BEARER##*/} --output-json | grep -oP '"address"\s*:\s*"\K[0-9a-f:]+(?=")' | head -1)
GW=$(mmcli -b ${BEARER##*/} --output-json | grep -oP '"gateway"\s*:\s*"\K[0-9a-f:]+(?=")' | head -1)
sudo ip -6 addr add ${IPV6}/64 dev wwan0
sudo ip -6 route add default via ${GW} dev wwan0

# Verify — should return your public IPv6
curl -6 -s --interface wwan0 https://ifconfig.me && echo

The IPv6 address assigned above is lost on reboot. To make it persistent, create a oneshot systemd service that re-applies it after each modem connection:

sudo tee /usr/local/bin/apply-ipv6.sh << 'EOF'
#!/bin/bash
sleep 5
BEARER=$(mmcli -m 0 --output-json | grep -o "/org/freedesktop/ModemManager1/Bearer/[0-9]*" | tail -1)
IPV6=$(mmcli -b ${BEARER##*/} --output-json 2>/dev/null | grep -oP '"address"\s*:\s*"\K[0-9a-f:]+(?=")' | head -1)
GW=$(mmcli -b ${BEARER##*/} --output-json 2>/dev/null | grep -oP '"gateway"\s*:\s*"\K[0-9a-f:]+(?=")' | head -1)
if [ -n "$IPV6" ] && [ -n "$GW" ]; then
    ip -6 addr add ${IPV6}/64 dev wwan0 2>/dev/null || true
    ip -6 route add default via ${GW} dev wwan0 2>/dev/null || true
fi
EOF
sudo chmod +x /usr/local/bin/apply-ipv6.sh
sudo tee /etc/systemd/system/wwan-ipv6.service << 'EOF'
[Unit]
Description=Apply IPv6 to wwan0
After=ModemManager.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/apply-ipv6.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload && sudo systemctl enable --now wwan-ipv6

Verify IPv6 survives a reboot:

sudo reboot
# after reconnecting:
ip -6 addr show wwan0
# expected: a global IPv6 address (starting with 2a01: or similar)

Set up the web server

As CGNAT and Mobile Carriers firewalls often block inbound requests on their 4G networks, we need to use a reverse tunnel. Many solutions are available: Cloudflare Tunnel, using a VPS to do traffic forwarding, etc.

We will use Cloudflare tunnel for simplicity, costs and as it doesn’t require additional resources. In this tutorial we assume you already acquired and set up your domain on Cloudflare (see prerequisites above).

Deploy Hugo blog to the Raspberry Pi with darkhttpd

Build the Hugo site locally

Install Hugo and scaffold a new site with a theme:

# Install Hugo (macOS)
brew install hugo

# Create a new site
hugo new site myblog && cd myblog

# Add a theme (e.g. Ananke)
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml

# Create your first post
hugo new content posts/hello-world.md
# Edit content/posts/hello-world.md, set draft: false

# Preview locally
hugo server

When ready to deploy, build the production site:

hugo --environment production --minify

This generates the static site in public/ (all the HTML, CSS, JS and assets ready to be served).

Sync to the Pi

Replace <user> and <hostname> with the values you set in the imager.

ssh <user>@<hostname> 'sudo mkdir -p /var/www/myblog && sudo chown <user>:<user> /var/www/myblog'
rsync -avz --delete public/ <user>@<hostname>:/var/www/myblog/
Install darkhttpd
ssh -t <user>@<hostname> 'sudo apt-get update -qq && sudo apt-get install -y -qq darkhttpd'
Create the systemd service

The tunnel will be configured to point to http://localhost:8080, so darkhttpd must listen on port 8080.

sudo tee /etc/systemd/system/myblog.service << 'EOF'
[Unit]
Description=My blog (darkhttpd)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/bin/darkhttpd /var/www/myblog --port 8080
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
Enable and start
sudo systemctl daemon-reload
sudo systemctl enable --now myblog
sudo systemctl status myblog

Expected output: Active: active (running)

Verify locally
curl -s http://localhost:8080/ | head -5

Expected output: the first lines of your blog’s HTML. The blog is now served on port 8080, ready for the Cloudflare Tunnel to forward traffic to it.

Set up Cloudflare tunnel

Caveat: Orange blocks port 7844 on IPv4 cloudflared connects to Cloudflare’s edge on port 7844 (QUIC). Orange blocks this on IPv4 but allows it on IPv6. We force cloudflared to use IPv6 with --edge-ip-version 6 — this is why the IPv6 persistence setup above is required before this step.

Create the tunnel in Cloudflare
  1. Go to Cloudflare Zero Trust https://one.dash.cloudflare.com → Networks → Tunnels
  2. Create a tunnel (e.g. solar)
  3. Add public hostname: solar.<your_domain>.com → http://localhost:8080
  4. Copy the tunnel token
Install cloudflared on the Raspberry Pi
curl -fSL -o /tmp/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm
sudo mv /tmp/cloudflared /usr/local/bin/cloudflared
sudo chmod +x /usr/local/bin/cloudflared
Store the tunnel token
sudo mkdir -p /etc/cloudflared
echo 'TUNNEL_TOKEN=<your-token>' | sudo tee /etc/cloudflared/env
sudo chmod 600 /etc/cloudflared/env
Create the systemd service
sudo tee /etc/systemd/system/cloudflared.service << 'EOF'
[Unit]
Description=Cloudflare Tunnel
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --edge-ip-version 6 run --token ${TUNNEL_TOKEN}
EnvironmentFile=/etc/cloudflared/env
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

The key flag is --edge-ip-version 6: this forces cloudflared to connect to Cloudflare over IPv6, bypassing Orange’s port 7844 block on IPv4.

Enable and start
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared

Expected output: Active: active (running)

Verify
journalctl -u cloudflared --no-pager -n 10

Look for a line containing Connection established or Registered tunnel connection — this confirms the tunnel is up. You will also see the tunnel labelled as Healthy in the Cloudflare Zero Trust dashboard.

Cloudflare tunnel showing healthy status

Then from any device: curl https://solar.<your_domain>.com/

What’s next — Part 2: Going fully off-grid

The blog is live, served over 4G, tunnelled through Cloudflare. But it’s still plugged into a wall.

In Part 2 we’ll cut the cord entirely:

  • Solar panel — wiring a 10W USB-C panel to keep the Pi powered during the day
  • Battery hat (Pi Sugar 3 Plus) — storing energy to survive nights and cloudy days
  • GPS — using the built-in GPS module on the SIM7600 hat to geotag the device
  • Power tips — disabling unused hardware (HDMI, LEDs, USB) to stretch every milliwatt

Miscellaneous