FTP is old, but it is not going anywhere: cameras, PBXes, scanners, legacy ERPs and plenty of customers can speak nothing else. The job is to run it without the two classic mistakes — plaintext credentials on the wire and users wandering around the filesystem. vsftpd ("very secure FTP daemon") is the right tool: small, audited, and the default FTP server in every major distribution.
This guide sets up vsftpd for local users with a chrooted home, TLS encryption (FTPS), passive mode behind a firewall, and no shell access. It covers Ubuntu/Debian and RHEL, AlmaLinux and Rocky Linux, with the SELinux and firewalld steps the RHEL side needs. If your clients can use SFTP (SSH file transfer) instead, do that — it is simpler and already installed; this guide is for when they cannot.
Install vsftpd
# Ubuntu / Debian
sudo apt update && sudo apt install -y vsftpd
# RHEL, AlmaLinux, Rocky, Fedora
sudo dnf install -y vsftpd
sudo systemctl enable --now vsftpd
On Debian/Ubuntu the service is enabled and started by the package. Config file: /etc/vsftpd.conf (Debian/Ubuntu) or /etc/vsftpd/vsftpd.conf (RHEL-family). The examples below say vsftpd.conf; use the right path.
Back it up first: sudo cp vsftpd.conf vsftpd.conf.orig.
Open the firewall: 21, and a passive port range
FTP uses port 21 for commands and a second connection for data. In passive mode (what every client behind NAT uses) the server picks the data port, so you must fix that range and open it:
# Ubuntu / Debian (ufw)
sudo ufw allow 21/tcp
sudo ufw allow 990/tcp # only if you enable implicit FTPS (rare)
sudo ufw allow 40000:40100/tcp # passive range, must match vsftpd.conf below
# RHEL-family (firewalld)
sudo firewall-cmd --permanent --add-service=ftp
sudo firewall-cmd --permanent --add-port=40000-40100/tcp
sudo firewall-cmd --reload
100 ports allows 100 simultaneous data connections, which is plenty for most servers. If the server is behind a cloud security group or a NAT router, open the same range there too, and note the public IP — you will need it for pasv_address.
Create the upload user
A dedicated user, with a shell that cannot log in interactively:
sudo useradd -m -d /srv/ftp/customer1 -s /usr/sbin/nologin customer1
sudo passwd customer1
vsftpd refuses logins for users whose shell is not in /etc/shells, so add nologin there once:
grep -qx /usr/sbin/nologin /etc/shells || echo /usr/sbin/nologin | sudo tee -a /etc/shells
The chroot directory layout
vsftpd refuses to chroot a user into a directory that user can write to (500 OOPS: vsftpd: refusing to run with writable root inside chroot()). The correct layout is a root-owned, non-writable home with a writable subdirectory inside it:
sudo chown root:root /srv/ftp/customer1
sudo chmod 755 /srv/ftp/customer1
sudo mkdir -p /srv/ftp/customer1/upload
sudo chown customer1:customer1 /srv/ftp/customer1/upload
sudo chmod 750 /srv/ftp/customer1/upload
echo "Upload files into the upload/ directory." | sudo tee /srv/ftp/customer1/README.txt
The user lands in / (their chroot), sees README.txt and upload/, and can write only inside upload/. This is cleaner than allow_writeable_chroot=YES, which disables the safety check entirely.
On RHEL-family systems with SELinux enforcing, also allow vsftpd to read and write user home content:
sudo setsebool -P ftpd_full_access on
(The narrower ftp_home_dir boolean covers home directories only; use ftpd_full_access for directories under /srv.)
Configure vsftpd
Replace the config file with this (adjust the two marked lines):
# ---- basics ----
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
xferlog_std_format=YES
xferlog_file=/var/log/vsftpd.log
ftpd_banner=FTP service.
# ---- chroot ----
chroot_local_user=YES
# do NOT set allow_writeable_chroot=YES; use the root-owned home + writable subdir layout
# ---- only listed users may log in ----
userlist_enable=YES
userlist_file=/etc/vsftpd.userlist
userlist_deny=NO
# ---- passive mode ----
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
pasv_address=203.0.113.10 # <-- your PUBLIC IP if behind NAT; delete the line otherwise
pasv_addr_resolve=NO
# ---- TLS (FTPS, explicit on port 21) ----
ssl_enable=YES
rsa_cert_file=/etc/ssl/vsftpd/fullchain.pem # <-- your certificate
rsa_private_key_file=/etc/ssl/vsftpd/privkey.pem # <-- your key
force_local_data_ssl=YES
force_local_logins_ssl=YES
ssl_tlsv1_2=YES
ssl_tlsv1_3=YES # vsftpd >= 3.0.5 only; delete this line on RHEL/Alma/Rocky 8 (3.0.3)
ssl_tlsv1_1=NO
ssl_tlsv1=NO
ssl_sslv2=NO
ssl_sslv3=NO
require_ssl_reuse=NO
ssl_ciphers=HIGH
# ---- limits ----
idle_session_timeout=600
data_connection_timeout=120
max_clients=50
max_per_ip=5
Notes on the choices:
userlist_deny=NOturns/etc/vsftpd.userlistinto an allow list. Add the user:echo customer1 | sudo tee -a /etc/vsftpd.userlist. Nobody else — notroot, notpostgres— can log in over FTP.pasv_addressis only needed when the server does not know its own public IP (cloud VM behind 1:1 NAT, home router). Without it, the server advertises its private address and clients hang onLIST.ssl_tlsv1_3is unknown to vsftpd 3.0.3 (RHEL-family 8) and makes it refuse to start with500 OOPS: unrecognised variable in config file; remove the line there — TLS 1.2 is still enforced.require_ssl_reuse=NOis a compatibility setting: many clients (and all browsers) cannot reuse the control-connection TLS session for data connections and fail without it.- Explicit FTPS on port 21 (
AUTH TLS) is the standard; implicit FTPS on 990 is legacy. If a client insists on 990, add a second listener withimplicit_ssl=YESin a separate config and systemd instance.
The certificate
For a server with a public hostname, use Let's Encrypt and copy (not symlink — vsftpd runs chrooted) the files into place on each renewal:
sudo certbot certonly --standalone -d ftp.example.com
sudo mkdir -p /etc/ssl/vsftpd
sudo install -m 600 /etc/letsencrypt/live/ftp.example.com/fullchain.pem /etc/ssl/vsftpd/
sudo install -m 600 /etc/letsencrypt/live/ftp.example.com/privkey.pem /etc/ssl/vsftpd/
Put the two install lines plus systemctl restart vsftpd in /etc/letsencrypt/renewal-hooks/deploy/vsftpd.sh so renewals keep working. For an internal server a self-signed certificate is fine:
sudo openssl req -x509 -nodes -days 1095 -newkey rsa:2048 \
-keyout /etc/ssl/vsftpd/privkey.pem -out /etc/ssl/vsftpd/fullchain.pem \
-subj "/CN=ftp.internal"
Apply
sudo systemctl restart vsftpd
sudo systemctl status vsftpd --no-pager
sudo ss -tlnp | grep :21
A config typo makes vsftpd exit immediately; sudo journalctl -u vsftpd -n 20 names the offending line (500 OOPS: unrecognised variable in config file).
Test from a client
With lftp (handles TLS and passive mode cleanly):
sudo apt install -y lftp # or dnf install lftp
lftp -u customer1 -e 'set ftp:ssl-force true; set ssl:verify-certificate yes; ls; cd upload; put /etc/hostname; ls; bye' ftp.example.com
Expect README.txt and upload/ in the first listing and your uploaded file in the second. With a self-signed certificate, set ssl:verify-certificate no for the test. In FileZilla: Protocol FTP, Encryption Require explicit FTP over TLS, Logon type Normal.
Then prove the restrictions hold: cd .. should keep you at /, and put in / should fail with 553 Could not create file.
Logs and monitoring
- Transfers:
/var/log/vsftpd.log(xferlog format — who uploaded what, from where, how many bytes). - Logins and errors:
sudo journalctl -u vsftpdand/var/log/auth.logor/var/log/securefor PAM failures. - Brute force: vsftpd is a common target. The
vsftpdjail in fail2ban is pre-defined — enable it injail.localexactly as you would thesshdjail (see stopping SSH brute-force attacks). - Availability: an FTP uptime check that logs in every minute catches an expired certificate or a dead daemon before a customer's nightly upload fails.
Troubleshooting
500 OOPS: vsftpd: refusing to run with writable root inside chroot()— the user's home is writable by the user. Use the root-owned home + writableupload/layout above; do not reach forallow_writeable_chroot=YES.530 Login incorrect— one of: wrong password; shell not in/etc/shells; user not in/etc/vsftpd.userlist(withuserlist_deny=NO); user listed in/etc/vsftpd/ftpusers(RHEL's PAM deny file includesroot); or on RHEL, PAM/SELinux — checkjournalctl -u vsftpdandausearch -m avc -c vsftpd -ts recent.- Login works,
LISThangs or425 Failed to establish connection— passive mode. The passive range is not open in the firewall/security group, orpasv_addressis missing/wrong behind NAT.lftp -dshows the address the server advertised in its227 Entering Passive Modereply. 522 SSL connection failed; session reuse required— setrequire_ssl_reuse=NO.500 OOPS: cannot change directory:/srv/ftp/customer1on RHEL — SELinux:setsebool -P ftpd_full_access on, and check the directory context withls -Z.500 OOPS: priv_sock_get_cmd— usually an upload into a directory the user cannot write, or seccomp on older kernels; tryseccomp_sandbox=NOas a diagnostic only.- Works on the LAN, not from the internet — the router/security group forwards 21 but not 40000–40100, or
pasv_addressis the private IP. - Uploads are owned by the wrong user / unreadable by the app —
local_umask=022pluschown_uploads=YES/chown_username=www-dataif another process must own the files.
Summary
- Install vsftpd; open 21 and a fixed passive range in every firewall on the path.
- Dedicated user with
/usr/sbin/nologin(added to/etc/shells), root-owned chroot home, writableupload/subdirectory. chroot_local_user=YES, allow-list viauserlist_enable+userlist_deny=NO.- Explicit FTPS:
ssl_enable=YES, force TLS for logins and data, TLS 1.2/1.3 only,require_ssl_reuse=NO. pasv_address= public IP when behind NAT;setsebool -P ftpd_full_access onon RHEL.- Test with
lftp, watch/var/log/vsftpd.log, add the fail2ban jail and an FTP uptime check.
Related Resources
- How to set up FTP uptime monitoring — a login check every minute, so a dead daemon or expired certificate pages you, not the customer
- How to monitor ProFTPD — if you run ProFTPD instead, the equivalent metrics and checks
- How to renew an SSL certificate step by step — the renewal hook pattern used above, in detail
- 5 ways to stop SSH brute-force attacks on Linux — the same fail2ban setup protects the FTP login