While modern IoT stacks often default to MQTT or HTTPS, the File Transfer Protocol (FTP) remains a stubborn workhorse in industrial SCADA systems, legacy CNC machines, and remote environmental data loggers. If you are integrating a Raspberry Pi into an existing facility that only accepts FTP drops, or building a remote camera trap that uploads to a shared hosting provider, you need a robust implementation. A naive Python script will eventually hang on a passive-mode timeout or fail silently when the network drops.
This guide walks through building a hardware-triggered Raspberry Pi FTP client. We will wire physical GPIO status indicators, write a production-ready Python script using ftplib and gpiozero, and dissect the exact error strings that plague embedded FTP deployments.
Project Overview & Hardware Specifications
This build assumes you are running headless or with a minimal desktop environment. We use gpiozero rather than the deprecated RPi.GPIO library, ensuring native compatibility with the Pi 5's RP1 southbridge chip.
Bill of Materials (BOM)
- Compute: Raspberry Pi 5 (4GB) or Pi 4 Model B (2GB+)
- Storage: 32GB SanDisk Extreme microSD (A2 rated for high IOPS during CSV writes)
- Network: Cat6 Ethernet cable (Wi-Fi is acceptable, but Ethernet is strongly preferred for remote FTP reliability)
- Indicators: 2x 5mm Diffused LEDs (1x Green, 1x Red)
- Trigger: 1x Momentary tactile pushbutton (6x6mm)
- Passives: 2x 220Ω through-hole resistors (for LEDs), 1x 10kΩ resistor (for button pull-up if not using internal)
- Wiring: Breadboard and female-to-male jumper wires
Protocol Selection Matrix: FTP vs SFTP vs FTPS
Before writing code, you must confirm which protocol your destination server actually requires. Many users search for "Raspberry Pi FTP" when they actually need SFTP. Standard FTP sends credentials in cleartext, which is a severe security risk over the public internet, but it is still standard for isolated VLANs and local LAN data drops.
| Feature | Standard FTP | FTPS (FTP over TLS) | SFTP (SSH File Transfer) |
|---|---|---|---|
| Default Port(s) | 21 (Command), 20 (Active Data) | 21 (Command), 990 (Implicit) | 22 (Single Port) |
| Encryption | None (Cleartext) | TLS/SSL (Certificates) | SSH (Key-based or Password) |
| Pi CPU Overhead | Negligible (<1%) | Moderate (TLS handshake) | Moderate (SSH cipher) |
| Python Library | ftplib.FTP |
ftplib.FTP_TLS |
paramiko or pysftp |
| NAT/Firewall Traversal | Poor (Requires Passive Mode + Port Range) | Poor (Same as FTP, plus cert validation) | Excellent (Single port 22) |
Note: The code provided in this guide uses standard FTP via ftplib. If your server requires SFTP, you must install paramiko (pip install paramiko) and rewrite the transport layer, as ftplib does not support SSH.
GPIO Pin Mapping & Wiring
We use physical LEDs to provide immediate bench-level feedback. When deploying a Pi in an enclosure, a red LED glowing through a light pipe saves you from plugging in a monitor just to see if the script crashed.
Resistor Math: The Pi's GPIO outputs 3.3V. A standard red LED has a forward voltage of ~2.0V and a max current of 20mA. (3.3V - 2.0V) / 0.020A = 65Ω. We use 220Ω resistors to limit current to ~6mA, which is bright enough for indication and preserves the Pi's total GPIO current budget.
| Component | Pi BCM Pin | Physical Pin (40-pin header) | Wiring Notes |
|---|---|---|---|
| Green LED (Success) | GPIO 17 | Pin 11 | Anode to Pin 11, Cathode to 220Ω Resistor, then to GND |
| Red LED (Failure) | GPIO 27 | Pin 13 | Anode to Pin 13, Cathode to 220Ω Resistor, then to GND |
| Pushbutton (Trigger) | GPIO 22 | Pin 15 | One side to Pin 15, other side to GND (Uses internal pull-up) |
The Python FTP Upload Script
This script targets Raspberry Pi OS 64-bit. It generates a dummy CSV sensor log, waits for the physical button press, and uploads the file. It includes robust exception handling to catch network timeouts and permission errors, lighting the appropriate LED based on the result.
Ensure you have the required libraries installed:
sudo apt update
sudo apt install python3-gpiozero python3-full
Save the following code as ftp_logger.py:
import time
import csv
import os
from datetime import datetime
from ftplib import FTP, error_perm, error_temp
from gpiozero import LED, Button
from signal import pause
# --- PIN DEFINITIONS ---
GREEN_LED = LED(17)
RED_LED = LED(27)
TRIGGER_BTN = Button(22, pull_up=True, bounce_time=0.1)
# --- FTP CONFIGURATION ---
FTP_HOST = '192.168.1.100'
FTP_USER = 'datalogger'
FTP_PASS = 'secure_password_123'
REMOTE_DIR = '/uploads/sensor_data/'
LOCAL_FILE = '/home/pi/sensor_log.csv'
def generate_dummy_csv():
"""Creates a mock sensor CSV file for upload."""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'log_{timestamp}.csv'
filepath = os.path.join('/tmp', filename)
with open(filepath, mode='w', newline='') as file:
writer = csv.writer(file)
writer.writerow(['timestamp', 'temp_c', 'humidity_pct'])
for i in range(10):
writer.writerow([datetime.now().isoformat(), 22.5 + i*0.1, 45.0])
return filepath, filename
def upload_via_ftp(local_path, remote_name):
"""Handles the FTP connection, upload, and teardown."""
GREEN_LED.off()
RED_LED.off()
try:
print(f"Connecting to {FTP_HOST}...")
# Timeout set to 10 seconds to prevent indefinite hangs on bad routes
ftp = FTP(FTP_HOST, timeout=10)
ftp.login(user=FTP_USER, passwd=FTP_PASS)
# Force passive mode (crucial for NAT traversal)
ftp.set_pasv(True)
# Change to remote directory, create if missing
try:
ftp.cwd(REMOTE_DIR)
except error_perm:
ftp.mkd(REMOTE_DIR)
ftp.cwd(REMOTE_DIR)
print(f"Uploading {remote_name}...")
with open(local_path, 'rb') as f:
ftp.storbinary(f'STOR {remote_name}', f)
ftp.quit()
print("Upload successful.")
GREEN_LED.blink(0.5, 0.5, 3, background=False) # Blink green 3 times
return True
except error_perm as e:
print(f"FTP Permission Error: {e}")
RED_LED.on()
return False
except error_temp as e:
print(f"FTP Temporary Error (Server busy): {e}")
RED_LED.blink(0.2, 0.2, 5, background=False)
return False
except TimeoutError as e:
print(f"Network Timeout: {e}")
RED_LED.on()
return False
except ConnectionRefusedError as e:
print(f"Connection Refused: {e}")
RED_LED.on()
return False
except Exception as e:
print(f"Unexpected Error: {e}")
RED_LED.on()
return False
finally:
# Clean up local temp file
if os.path.exists(local_path):
os.remove(local_path)
def main():
print("Raspberry Pi FTP Logger Ready. Press button to trigger upload.")
while True:
TRIGGER_BTN.wait_for_press()
print("Button pressed. Generating data...")
local_path, remote_name = generate_dummy_csv()
upload_via_ftp(local_path, remote_name)
time.sleep(1) # Debounce delay
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print("\nExiting safely.")
GREEN_LED.off()
RED_LED.off()
Debugging: Exact Error Strings & The First Three Checks
When deploying FTP on embedded Linux, the network stack and server configuration will inevitably throw errors. Here is how to decode the exact Python exceptions and the first three things you must check when the script fails.
The First Three Things to Check
- Verify Port 21 Reachability: Before blaming Python, verify the network route. From the Pi terminal, run
nc -zv 192.168.1.100 21. If it times out, your server is down, or a local firewall (like UFW on the server) is blocking port 21. - Check Passive Mode Port Ranges: If the script connects but hangs during the
storbinaryupload, your FTP server is likely in Passive Mode, but the router/firewall isn't forwarding the passive data ports. Check your server'svsftpd.confforpasv_min_portandpasv_max_portand ensure that range is open on the firewall. - Confirm PAM and User Shell Settings: If using
vsftpdon a Linux server, the user must have a valid shell (e.g.,/bin/bashor/usr/sbin/nologin) and PAM authentication must be correctly configured in/etc/pam.d/vsftpd.
Exact Error Strings and Ranked Causes
ftplib passes the server's raw 3-digit SMTP/FTP response codes directly into the exception string.
1. ConnectionRefusedError: [Errno 111] Connection refused
- Cause A: The FTP daemon (vsftpd, ProFTPD) is not running on the target IP.
- Cause B: You are targeting the wrong IP address or the server's local firewall (iptables/UFW) is actively rejecting port 21.
2. ftplib.error_perm: 530 Login incorrect
- Cause A: Typo in the username or password in your Python script.
- Cause B: The FTP server is configured to reject plaintext logins. If the server enforces TLS, you must switch your Python code from
FTP()toFTP_TLS()and callftp.prot_p()to secure the data channel. - Cause C: The user account is locked or missing from the server's
/etc/passwdor FTP-specific user database.
3. TimeoutError: [Errno 110] Connection timed out (Specifically during STOR)
- Cause A: The classic Passive Mode NAT issue. The Pi successfully sent the command on port 21, but the server replied with an internal LAN IP for the data connection, which the Pi cannot route to. Fix: Configure the FTP server with
pasv_address=YOUR_PUBLIC_IP. - Cause B: The passive port range is blocked by an upstream router.
For deeper protocol specifications, refer to the official Python ftplib documentation and the GPIO Zero API reference.
Extending and Simplifying the Build
Depending on your deployment environment, you may need to scale this project up for industrial reliability or scale it down for a quick weekend hack.
How to Simplify
If you control both the Pi and the receiving server, drop FTP entirely and use HTTP POST. Setting up an FTP server requires managing user accounts, passive port ranges, and chroot jails. Instead, run a lightweight Node-RED or Python Flask endpoint on your server. You can then replace the entire ftplib block with a two-line requests.post() call. This eliminates NAT traversal headaches and uses a single port (80 or 443).
How to Extend for Remote Deployments
If this Pi is sitting in a solar-powered enclosure in a field, a network hang will eventually freeze the script or the OS. You must implement a watchdog.
- Software Watchdog: Enable the Raspberry Pi's hardware watchdog timer via
systemd. If the Python script hangs and fails to ping the watchdog service, the Pi will hard-reboot. - Automated Cron Trigger: Remove the physical button and wrap the
upload_via_ftp()function in asystemdtimer orcronjob to run every 15 minutes. Use file-locking (fcntl) to prevent overlapping uploads if the network is sluggish. - Cellular Fallback: Add a Waveshare SIM7600 4G HAT. If the Ethernet/Wi-Fi ping fails, route the FTP traffic through the cellular PPP0 interface using Python's socket binding capabilities.
By combining physical GPIO feedback with rigorous Python exception handling, your Raspberry Pi FTP logger transitions from a fragile bench prototype to a deployable field instrument. Always test your passive port configurations locally before sealing the enclosure.






