The most reliable way to remote into a Raspberry Pi for embedded hardware projects is via SSH over a Tailscale mesh network. This gives you secure, zero-config CLI access from anywhere without exposing port 22 to the public internet. For this guide, we are targeting the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS Lite (64-bit), building a headless TCP-controlled GPIO relay server.

Decision Tree: Which Remote Access Stack to Pick

Choosing the wrong remote access method leads to dropped connections, blocked ports, and bricked headless setups. Use this decision matrix to select your stack. We terminate on the optimal choice for 95% of embedded IoT and debugging scenarios.

Use Case Method Pros Cons
Local network CLI debugging Standard SSH (mDNS) Zero setup, native to OS Fails outside local subnet
Local GUI / Desktop access RealVNC / WayVNC Visual desktop environment Heavy CPU/RAM overhead on Pi
Remote IoT telemetry / async MQTT + Node-RED Decoupled, highly scalable Overkill for direct CLI debugging
Remote CLI + GPIO control Tailscale + SSH Works anywhere, no port forwarding, encrypted Requires 3rd-party auth account
The Concrete Pick: For headless embedded projects where you need to SSH in, read logs, and trigger Python GPIO scripts from your laptop or phone while away from the bench, use Tailscale + SSH. It bypasses NAT traversal issues and CGNAT restrictions inherent to modern ISPs.

Parts List & Hardware Spec Sheet

This build assumes a physical hardware control element to demonstrate remote GPIO triggering. Do not use generic phone chargers for the Pi 5; it requires USB-C PD negotiation to deliver full current to the GPIO headers.

  • Compute: Raspberry Pi 5 (8GB RAM) - ~$80 USD
  • Power: Official Raspberry Pi 27W USB-C PD Power Supply - ~$12 USD
  • Storage: SanDisk Extreme 64GB microSD (A2 rating) or official NVMe SSD - ~$15 USD
  • Thermal: Official Active Cooler (mandatory for Pi 5 under load) - ~$5 USD
  • Actuator: Omron G5V-2 5V DC DPDT Relay Module (Opto-isolated) - ~$4 USD
  • Input: Momentary tactile pushbutton (12x12mm)

GPIO Pin Mapping Table

The Python script below targets these exact physical pins. Wire the opto-isolated relay to avoid back-EMF frying the Pi 5's BCM2712 GPIO bank.

Physical Pin BCM GPIO Function Wiring Destination
Pin 11 GPIO 17 Digital Output Relay Module IN (Signal)
Pin 13 GPIO 27 Digital Input Pushbutton (Normally Open)
Pin 1 3V3 Power Pushbutton (Common)
Pin 6 GND Ground Relay GND & Pi Ground Plane

Step-by-Step: Configuring Headless SSH & Tailscale

Follow these steps to flash, configure, and secure the network stack before writing any application code.

  1. Flash the OS: Use Raspberry Pi Imager. Select Raspberry Pi OS Lite (64-bit). In the advanced settings (gear icon), enable SSH (Use password authentication), set your hostname to pi5-relay, and configure your local WiFi.
  2. Boot and Connect: Insert the microSD, apply power, and wait 90 seconds. From your host machine, ping the mDNS address: ping pi5-relay.local.
  3. SSH In: Connect via terminal: ssh your_username@pi5-relay.local.
  4. Update the System: Run sudo apt update && sudo apt upgrade -y to patch the BCM2712 kernel and firmware.
  5. Install Tailscale: Execute the official install script:
    curl -fsSL https://tailscale.com/install.sh | sh
    sudo tailscale up
  6. Authenticate: The terminal will output a URL. Open it on your phone or laptop to authorize the Pi 5 to your private Tailnet. You will now receive a static 100.x.y.z IP address for this Pi.
Safety & Code Caveat: If you are wiring the relay to mains voltage (>50V AC) to control a lamp or motor, de-energize the circuit, lock out the breaker, and verify dead with a CAT III multimeter before connecting the Omron relay contacts. NEC-style guidance requires proper enclosure and strain relief for mains wiring; consult a licensed electrician if unsure.

Remote GPIO Control: The Python Server Script

This complete, compilable Python script creates a lightweight TCP socket server. It listens for remote commands to toggle the relay and monitors the physical pushbutton. It uses the gpiozero library, which is pre-installed on Raspberry Pi OS Lite.

#!/usr/bin/env python3
"""
Remote GPIO TCP Server for Raspberry Pi 5
Target Board: Raspberry Pi 5 (8GB) running Pi OS Lite 64-bit
Dependencies: gpiozero (native), socket, threading
"""

import socket
import threading
import time
import sys
from gpiozero import LED, Button
from signal import pause

# --- PIN DEFINITIONS ---
RELAY_PIN = 17   # Physical Pin 11
BUTTON_PIN = 27  # Physical Pin 13

# --- HARDWARE INITIALIZATION ---
# Using LED class for relay as it maps cleanly to digital on/off
relay = LED(RELAY_PIN, active_high=True) 
# Button with internal pull-up, active state is False (pressed connects to GND)
button = Button(BUTTON_PIN, pull_up=True, bounce_time=0.05) 

HOST = '0.0.0.0'  # Listen on all interfaces (Tailscale + Local)
PORT = 65432

def handle_button_press():
    print("[HW] Physical button pressed. Toggling relay.")
    relay.toggle()

def handle_client(conn, addr):
    print(f"[NET] Connected by {addr}")
    try:
        while True:
            data = conn.recv(1024).decode('utf-8').strip().upper()
            if not data:
                break
            
            if data == 'ON':
                relay.on()
                conn.sendall(b"RELAY STATE: ON\n")
            elif data == 'OFF':
                relay.off()
                conn.sendall(b"RELAY STATE: OFF\n")
            elif data == 'STATUS':
                state = "ON" if relay.is_lit else "OFF"
                conn.sendall(f"RELAY STATE: {state}\n".encode('utf-8'))
            else:
                conn.sendall(b"ERR: Unknown command. Use ON, OFF, STATUS.\n")
    except ConnectionResetError:
        print(f"[NET] Client {addr} disconnected abruptly.")
    finally:
        conn.close()
        print(f"[NET] Connection closed for {addr}")

def main():
    # Bind physical button to toggle function
    button.when_pressed = handle_button_press
    
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
    try:
        server_socket.bind((HOST, PORT))
        server_socket.listen(5)
        print(f"[SYS] TCP Server listening on {HOST}:{PORT}")
        print("[SYS] Send 'ON', 'OFF', or 'STATUS' via netcat or telnet.")
        
        while True:
            conn, addr = server_socket.accept()
            client_thread = threading.Thread(target=handle_client, args=(conn, addr))
            client_thread.daemon = True
            client_thread.start()
            
    except KeyboardInterrupt:
        print("\n[SYS] Shutting down server...")
    except OSError as e:
        print(f"[ERR] Socket binding failed: {e}")
        sys.exit(1)
    finally:
        # Ensure hardware is left in a safe state on exit
        relay.off()
        server_socket.close()
        print("[SYS] Relay OFF. Ports closed. Exiting.")

if __name__ == "__main__":
    main()

How to test remotely: From your laptop (connected to the same Tailscale network), open a terminal and type:
echo "ON" | nc 100.x.y.z 65432
Replace 100.x.y.z with your Pi's Tailscale IP. The relay will click, and the terminal will return RELAY STATE: ON.

Debugging: Exact Errors & The First Three Checks

When remote embedded setups fail, the issue is almost always network routing, daemon status, or GPIO namespace collisions. Here is how to debug the exact errors you will encounter.

Error 1: ssh: connect to host 100.x.y.z port 22: Connection refused

Ranked Causes:

  1. SSH Daemon is disabled: Raspberry Pi OS Lite ships with SSH disabled by default for security. If you forgot to place the ssh file in the boot partition or enable it in Imager, the daemon isn't running.
  2. Tailscale is down: The Pi lost internet and Tailscale's DERP relay fallback failed, or the service crashed.
  3. IP Change: You are using a stale local IP instead of the static Tailscale IP.

Error 2: gpiozero.exc.GPIOPinInUse: pin 17 is already in use

Ranked Causes:

  1. Zombie Python Process: You killed a previous run of the script with kill -9 before it could hit the finally block, leaving the BCM2712 pin locked in the kernel's pinctrl subsystem.
  2. Device Tree Overlay Conflict: An entry in /boot/firmware/config.txt (like dtoverlay=gpio-ir) has claimed GPIO 17 at the hardware level.
The First Three Things to Check When It Fails:
  1. Check SSH Service: Run systemctl status ssh. If it says inactive, run sudo systemctl enable --now ssh.
  2. Verify Network Route: Run tailscale status on your host machine to ensure the Pi shows as "active" and note the exact 100.x.y.z IP.
  3. Check Interface Settings: Run sudo raspi-config, navigate to Interface Options > SSH, and ensure it is explicitly enabled.

Extending and Simplifying the Build

Depending on your deployment environment, you should adapt this baseline architecture.

How to Simplify (Local Bench Only)

If this Pi never leaves your local network and you don't want to manage a Tailscale account, strip the mesh VPN out entirely. Rely on mDNS (Multicast DNS). Ensure the avahi-daemon package is installed (sudo apt install avahi-daemon), and simply SSH into ssh user@pi5-relay.local. This removes the internet dependency but restricts access to your local subnet.

How to Extend (Production IoT Deployment)

For a remote deployment where the Pi is controlling critical infrastructure (e.g., a greenhouse heater or server room AC), TCP sockets are too fragile. Extend the build by:

  • Adding MQTT: Replace the TCP socket with the paho-mqtt library. Subscribe to a topic like site1/greenhouse/heater/cmd and publish state to site1/greenhouse/heater/status.
  • Hardware Watchdog: Enable the BCM2712 hardware watchdog in config.txt and use the watchdog systemd service to automatically hard-reboot the Pi if the Python script hangs or the kernel panics.
  • Systemd Service: Wrap the Python script in a /etc/systemd/system/relay-server.service file with Restart=always and RestartSec=5 to ensure it survives power blips and reboots.