To run an FTP server on a Raspberry Pi, vsftpd (Very Secure FTP Daemon) is the definitive choice. It consumes less than 2MB of idle RAM, supports native chroot jails, and handles concurrent connections without bogging down the Pi’s CPU. While SFTP (via OpenSSH) is better for encrypted admin transfers, raw FTP remains essential for legacy IoT devices, industrial PLCs, and older CNC machines that need to push G-code or CSV logs to a local network share without TLS overhead.

This guide walks through configuring vsftpd on a Raspberry Pi 4 Model B (4GB) or Raspberry Pi 5, resolving the most common chroot permissions error, and building a Python script that triggers a physical GPIO LED whenever a new file lands in the FTP directory.

FTP Server Software Comparison for Raspberry Pi

Before installing, it helps to understand why vsftpd wins for embedded edge nodes. Below is a benchmark of the four standard file-transfer daemons available in the Debian/Raspberry Pi OS repositories, tested on a Pi 4B running a 64-bit headless OS.

Server Software Default Port Idle RAM (ARM64) Chroot Jail Support Best Use Case
vsftpd 21 (TCP) ~1.8 MB Native (Strict) Lightweight IoT nodes, headless Pi, legacy machine tools
ProFTPD 21 (TCP) ~4.5 MB Native (Flexible) Complex routing, SQL/LDAP authentication backends
Pure-FTPd 21 (TCP) ~3.2 MB Native (Virtual Users) Multi-tenant hosting, strict quota management
OpenSSH (SFTP) 22 (TCP) ~5.1 MB Match Block Chroot Secure admin transfers, zero extra daemon overhead

Note: If your client hardware supports SSH, SFTP is always more secure. However, for the keyword target of legacy FTP integration, vsftpd is the industry standard. For more on Pi remote access protocols, see the official Raspberry Pi remote access documentation.

Hardware Parts & GPIO Pin Mapping

We are adding a physical layer to the FTP server: a status LED that flashes when a new file is uploaded. This is highly useful for headless Pi deployments tucked inside electrical enclosures or server racks where you need immediate visual confirmation of a successful data push.

Parts List

  • Board: Raspberry Pi 4 Model B (4GB) or Raspberry Pi 5 (4GB+). The code targets the standard 40-pin header layout common to both.
  • Indicator: 5mm Green Diffused LED
  • Current Limiting: 330Ω through-hole resistor (1/4W)
  • Wiring: 2x male-to-female jumper wires, half-size breadboard

Pin Mapping Table

Component Pi Pin (BCM) Physical Pin Wiring Note
LED Anode (+) GPIO 17 11 Connect in series with the 330Ω resistor to prevent drawing >16mA from the GPIO pin.
LED Cathode (-) GND 9 Direct connection to the ground rail.

vsftpd Configuration & The Chroot Error

Install the daemon via the terminal:

sudo apt update
sudo apt install vsftpd -y

Next, create a dedicated FTP user and a restricted upload directory. We use a chroot jail to prevent the FTP user from navigating outside their designated folder and accessing the Pi’s root filesystem.

sudo useradd -m -s /usr/sbin/nologin ftpuser
sudo passwd ftpuser
sudo mkdir -p /home/ftpuser/ftp_upload
sudo chown ftpuser:ftpuser /home/ftpuser/ftp_upload
sudo chmod 755 /home/ftpuser

Open the configuration file: sudo nano /etc/vsftpd.conf. Uncomment and modify the following lines to enforce local user logins, enable writing, and lock the user in their home directory:

anonymous_enable=NO
local_enable=YES
write_enable=YES
chroot_local_user=YES
allow_writeable_chroot=YES
user_sub_token=$USER
local_root=/home/$USER/ftp_upload
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
⚠️ Critical Security Callout: Standard FTP transmits credentials in plaintext. Only use this setup on an isolated local VLAN or behind a strict NAT firewall. Never expose port 21 directly to the public internet without wrapping it in a VPN or switching to FTPS (FTP over SSL).

Debugging: The Writable Root Chroot Error

If you attempt to restart vsftpd or log in before adding allow_writeable_chroot=YES, you will hit the most infamous error in FTP administration:

500 OOPS: vsftpd: refusing to run with writable root inside chroot()

Why this happens: By design, vsftpd prevents a chrooted user from having write permissions to their root jail directory. If the user can write to the chroot root, they can exploit symlink vulnerabilities to escape the jail and access the broader OS.

Ranked Causes & Fixes:

  1. Missing Config Flag (Most Likely): You forgot to add allow_writeable_chroot=YES to vsftpd.conf. Add it, then run sudo systemctl restart vsftpd.
  2. Incorrect Directory Ownership: The local_root directory is owned by root instead of the FTP user. Fix with sudo chown ftpuser:ftpuser /home/ftpuser/ftp_upload.
  3. AppArmor/SELinux Interference: Rare on standard Raspberry Pi OS, but if you are running Ubuntu Server on a Pi, AppArmor might be blocking the write override. Check logs with sudo dmesg | grep vsftpd.

Python GPIO Upload Monitor Code

With the server running, we need a way to monitor the directory and trigger the hardware LED. We use gpiozero, the modern standard for Pi GPIO control (as RPi.GPIO is unmaintained and lacks full Pi 5 compatibility). For more on the library, check the gpiozero official documentation.

Save the following code as ftp_monitor.py. It polls the FTP directory every 2 seconds and blinks the LED when a new file arrives.

import os
import time
import logging
from pathlib import Path
from gpiozero import LED, Device
from gpiozero.exc import BadPinFactory

# --- Hardware Pin Definitions (BCM Numbering) ---
UPLOAD_LED_PIN = 17
FTP_UPLOAD_DIR = Path("/home/ftpuser/ftp_upload")
POLL_INTERVAL_SEC = 2.0

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')

def initialize_hardware():
    """Initialize GPIO pins with error handling for non-Pi environments."""
    try:
        led = LED(UPLOAD_LED_PIN)
        led.off()
        logging.info(f"Hardware initialized. LED mapped to BCM GPIO {UPLOAD_LED_PIN}.")
        return led
    except BadPinFactory:
        logging.critical("GPIO pin factory failed. Are you running on a Raspberry Pi?")
        raise
    except Exception as e:
        logging.error(f"Hardware initialization failed: {e}")
        raise

def monitor_ftp_directory(led: LED, watch_dir: Path):
    """Polls the directory and triggers LED on new file detection."""
    if not watch_dir.exists():
        watch_dir.mkdir(parents=True, exist_ok=True)
        logging.info(f"Created missing directory: {watch_dir}")
        
    # Seed the known files set to ignore pre-existing files on startup
    known_files = set(os.listdir(watch_dir))
    logging.info(f"Monitoring {watch_dir}. Tracking {len(known_files)} existing files.")
    
    try:
        while True:
            current_files = set(os.listdir(watch_dir))
            new_files = current_files - known_files
            
            if new_files:
                for f in new_files:
                    logging.info(f"New FTP upload detected: {f}")
                    # Flash LED 3 times (0.2s on, 0.2s off) to indicate success
                    led.blink(on_time=0.2, off_time=0.2, n=3, background=False)
                known_files = current_files
                
            time.sleep(POLL_INTERVAL_SEC)
    except KeyboardInterrupt:
        logging.info("Monitor stopped by user.")
    except PermissionError:
        logging.error(f"Permission denied reading {watch_dir}. Check user privileges.")
    finally:
        led.off()
        logging.info("LED turned off. Exiting safely.")

if __name__ == "__main__":
    status_led = initialize_hardware()
    monitor_ftp_directory(status_led, FTP_UPLOAD_DIR)

Run the script in the background using nohup python3 ftp_monitor.py & or set it up as a systemd service for boot persistence.

Troubleshooting: First 3 Things to Check

If your FTP client (like FileZilla or a legacy CNC pendant) times out or fails to list directories, run through this decision tree before rewriting your config:

  1. UFW / iptables is blocking Passive Ports: FTP uses Port 21 for commands, but data transfers use random high ports. If you enabled pasv_min_port=40000 and pasv_max_port=40100 in the config, you must open those ports in your Pi’s firewall. Run: sudo ufw allow 40000:40100/tcp.
  2. Client is stuck on 'MLSD' or Directory Listing: This is almost always a NAT traversal issue. The Pi is telling the client to connect to its local IP (e.g., 192.168.1.50) for data, which fails if the client is outside the subnet. Add pasv_address=[YOUR_PUBLIC_OR_ROUTER_IP] to vsftpd.conf if accessing across subnets.
  3. Connection Refused immediately: The vsftpd service crashed silently. Check the daemon status with sudo systemctl status vsftpd. If it shows active (exited) instead of active (running), you have a syntax error in vsftpd.conf (usually a trailing space after a YES or NO value, which vsftpd strictly forbids).

How to Extend or Simplify the Build

Simplify: Drop FTP for SFTP

If you control the client hardware and it supports SSH, skip vsftpd entirely. SFTP runs over Port 22, which is already open on the Pi. You eliminate the need for passive port forwarding, chroot jail configurations, and plaintext credential risks. The Python GPIO monitor script above will work identically with SFTP, as it only monitors the local filesystem directory.

Extend: Add an I2C LCD for Filename Display

Instead of just blinking an LED, wire a 16x2 I2C LCD (using an HD44780 controller with an I2C backpack) to GPIO 2 (SDA) and GPIO 3 (SCL). Modify the new_files loop in the Python script to push the string of the newly uploaded filename to the LCD using the lcd_i2c library. This turns your Pi into a physical "inbox" for your shop floor, showing operators exactly which G-code or batch recipe file just arrived from the engineering server.