If you are building a headless embedded project, the Raspberry Pi SSH daemon is your only lifeline to the board. When it fails, you are left with a blinking green ACT LED and no terminal. The direct answer to the most common headless SSH failure is that the SSH service is disabled by default on Raspberry Pi OS; you must place an empty file named exactly ssh (with no .txt extension) in the root of the FAT32 boot partition before first boot.
This guide walks through a complete headless GPIO environmental controller build, provides the production-ready Python code to run it, and deeply debugs the exact SSH error strings you will encounter on the bench.
The Headless GPIO Controller: Parts and Pin Mapping
To give our SSH debugging a concrete hardware context, we are building a headless temperature-triggered relay controller. This build targets the Raspberry Pi 5 (8GB) and the Raspberry Pi 4 Model B. While the Pi 5 routes GPIO through the new RP1 southbridge chip, the gpiozero library abstracts this hardware difference, making the code compatible across both variants.
Parts List
- Microcontroller: Raspberry Pi 5 (8GB) or Raspberry Pi 4 Model B (4GB)
- Storage: 32GB SanDisk Extreme A2 microSD (UHS-I)
- Sensor: BME280 I2C Breakout Board (Adafruit 2652 or generic 3.3V variant)
- Actuator: 5V 1-Channel Relay Module with optocoupler isolation
- Power: Official 27W USB-C PD Power Supply (for Pi 5) or 15W (for Pi 4)
GPIO Pin Mapping Table
The BME280 operates strictly at 3.3V logic. Never connect its SDA/SCL lines to 5V, or you will destroy the sensor's internal I2C pull-ups. The relay module is powered by the 5V rail but triggered by a 3.3V GPIO signal.
| Component | Pin Label | Raspberry Pi GPIO / Power | Physical Pin # |
|---|---|---|---|
| BME280 | VCC / VIN | 3.3V Power | 1 |
| BME280 | GND | Ground | 6 |
| BME280 | SDA | GPIO 2 (I2C1 SDA) | 3 |
| BME280 | SCL | GPIO 3 (I2C1 SCL) | 5 |
| Relay Module | VCC | 5V Power | 2 |
| Relay Module | GND | Ground | 9 |
| Relay Module | IN (Signal) | GPIO 17 | 11 |
The Python Control Script
This script reads the BME280 chip ID to verify I2C communication, reads the temperature, and toggles GPIO 17 if the temperature exceeds a threshold. It includes robust error handling for I2C bus lockups and GPIO cleanup, which are common failure modes in headless deployments.
sudo raspi-config (Interface Options -> I2C) and install the required libraries: sudo apt install python3-gpiozero python3-smbus2.
#!/usr/bin/env python3
"""
Headless Environmental Relay Controller
Targets: Raspberry Pi 4 / Raspberry Pi 5
Dependencies: gpiozero, smbus2
"""
import time
import logging
import sys
from gpiozero import OutputDevice
from smbus2 import SMBus
# --- Pin & I2C Definitions ---
RELAY_GPIO = 17
I2C_BUS = 1
BME280_ADDR = 0x76 # Default for most generic breakouts (0x77 for Adafruit)
CHIP_ID_REG = 0xD0
TEMP_MSB_REG = 0xFA
# Configure logging for headless systemd journal output
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Initialize Relay (Active Low for most optocoupler relay modules)
relay = OutputDevice(RELAY_GPIO, active_high=False, initial_value=False)
def verify_i2c_sensor(bus):
"""Checks if the BME280 is actually responding on the I2C bus."""
try:
chip_id = bus.read_byte_data(BME280_ADDR, CHIP_ID_REG)
if chip_id == 0x60:
logging.info(f"BME280 verified. Chip ID: {hex(chip_id)}")
return True
else:
logging.error(f"Wrong Chip ID: {hex(chip_id)}. Check wiring.")
return False
except OSError as e:
logging.error(f"I2C Communication Failed: {e}")
return False
def read_raw_temp(bus):
"""Reads uncompensated temperature (simplified for demo)."""
# Note: Production code requires reading calibration registers (0x88-0xA1)
# and applying the Bosch compensation formula.
# Here we read the raw MSB to demonstrate bus reading.
raw_data = bus.read_i2c_block_data(BME280_ADDR, TEMP_MSB_REG, 3)
raw_temp = (raw_data[0] << 12) | (raw_data[1] << 4) | (raw_data[2] >> 4)
# Placeholder conversion (real conversion requires calibration params)
approx_temp_c = (raw_temp / 16384.0) - 25.0
return approx_temp_c
def main():
logging.info("Starting Headless GPIO Controller...")
with SMBus(I2C_BUS) as bus:
if not verify_i2c_sensor(bus):
logging.critical("Sensor not found. Exiting to prevent GPIO lockup.")
sys.exit(1)
try:
while True:
temp = read_raw_temp(bus)
logging.info(f"Approx Temp: {temp:.2f} C")
# Hysteresis logic to prevent relay chatter
if temp > 30.0 and not relay.value:
logging.warning("Temp high! Engaging relay.")
relay.on()
elif temp < 28.0 and relay.value:
logging.info("Temp normal. Disengaging relay.")
relay.off()
time.sleep(5)
except KeyboardInterrupt:
logging.info("SSH session terminated by user.")
except OSError as e:
logging.error(f"I2C Bus dropped: {e}")
finally:
relay.off()
relay.close()
logging.info("GPIO cleaned up. Relay secured in OFF state.")
if __name__ == "__main__":
main()
Debugging the "Connection Refused" SSH Error
When you attempt to connect to your headless Pi via terminal, you will eventually hit a wall. The most frequent and frustrating error is:
ssh: connect to host 192.168.1.50 port 22: Connection refused
This exact string means your computer successfully found the IP address on the local network (ARP resolution worked), but the Pi's operating system actively rejected the TCP connection on port 22. This is distinctly different from Connection timed out (which means the Pi is offline or on a different subnet).
Ranked Causes for "Connection Refused"
- The SSH Daemon is Disabled (90% of cases): Raspberry Pi OS disables the
sshdservice by default for security. If the OS did not detect thesshtrigger file in the boot partition during the first boot sequence, the service remains masked. - IP Address Collision or Change: You are pinging an IP address that the Pi previously held, but the DHCP lease expired and the router assigned it to another device (like a smartphone) that doesn't have an SSH server running.
- UFW / iptables Blocking Port 22: If you previously configured a firewall via SSH and saved the rules, a reboot might have loaded a strict profile that drops incoming port 22 traffic, resulting in a refusal or timeout.
The First Three Things to Check When It Fails
Before you pull the microSD card and start over, run through this diagnostic triage:
- Verify the Trigger File Extension: Windows hides known file extensions by default. If you created a file named
sshin Notepad, it is likely saved asssh.txt. The Pi bootloader strictly looks for a file namedsshwith zero characters after it. Use the command prompt (dir) or enable file extensions in Windows Explorer to verify. - Check the Router's DHCP Table: Log into your router's admin panel. Look for a device named
raspberrypi. Verify its MAC address starts withb8:27:eb,dc:a6:32, ore4:5f:01(the official Raspberry Pi OUI prefixes). If the IP doesn't match what you are trying to SSH into, update your terminal command. - Ping the Target: Run
ping 192.168.1.50(replace with your IP). If you get replies, the Pi is on and connected to the network, confirming the issue is strictly thesshdservice state. If it times out, the Pi hasn't booted, hasn't connected to WiFi, or is on a different VLAN.
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! error. This is SSH protecting you from a man-in-the-middle attack. Clear the old key from your PC by running: ssh-keygen -R 192.168.1.50.
Extending and Simplifying the Build
Once your Raspberry Pi SSH connection is stable and the Python script is running, you need to decide how to manage it long-term.
How to Simplify (The Set-and-Forget Route)
If you just want the relay to run autonomously without keeping an SSH terminal open, convert the Python script into a systemd service. This ensures the script starts on boot, restarts if it crashes, and logs to the system journal.
- Create a service file:
sudo nano /etc/systemd/system/env-relay.service - Add the standard
[Unit],[Service](withExecStart=/usr/bin/python3 /home/pi/controller.pyandRestart=always), and[Install]blocks. - Enable it:
sudo systemctl enable --now env-relay.service.
How to Extend (The IoT Route)
To extend this into a full IoT node, integrate the Eclipse Paho MQTT library. Instead of hardcoding the 30°C threshold, publish the BME280 temperature to an MQTT broker (like Mosquitto) every 5 seconds, and subscribe to a home/relay/set topic to allow remote toggling from Home Assistant or Node-RED without needing to maintain an active SSH session.
Raspberry Pi SSH FAQ
How do I enable Raspberry Pi SSH without a monitor or keyboard?
Flash Raspberry Pi OS using the official Raspberry Pi Imager. Before clicking 'Write', click the gear icon (Advanced Options). Check the box for 'Enable SSH' and select 'Use password authentication'. The Imager will automatically inject the correct ssh trigger file and configure the sshd_config file on the boot partition for you, entirely bypassing the need for a physical display.
Why does my Raspberry Pi SSH connection drop after 10 minutes?
This is caused by your router's NAT table or the Pi's power management dropping idle TCP connections. To fix this, force the SSH client to send keep-alive packets. On your host computer, edit your ~/.ssh/config file and add ServerAliveInterval 60 under your Pi's host block. This sends a null packet every 60 seconds, keeping the NAT tunnel open indefinitely.
Can I use Raspberry Pi SSH over USB without a WiFi or Ethernet connection?
Yes, this is called USB OTG Ethernet. By editing the config.txt file to include dtoverlay=dwc2 and adding modules-load=dwc2,g_ether to cmdline.txt, the Pi will emulate an Ethernet adapter when plugged directly into your PC's USB port. You can then SSH into the static link-local address ssh pi@raspberrypi.local without any network infrastructure.
Is it safe to expose my Raspberry Pi SSH port to the internet?
No. Exposing port 22 directly via port forwarding will result in automated botnets brute-forcing your credentials within hours. If you need remote access outside your local network, use a reverse SSH tunnel, a zero-trust overlay network like Tailscale, or configure a Cloudflare Tunnel. These methods keep port 22 closed on your router while providing secure remote terminal access.






