DevOps & Workflow8 min read

    Set Up a Secure FTP Server on Linux with vsftpd

    By Amir— Reliability & Network Engineering
    Share

    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=NO turns /etc/vsftpd.userlist into an allow list. Add the user: echo customer1 | sudo tee -a /etc/vsftpd.userlist. Nobody else — not root, not postgres — can log in over FTP.
    • pasv_address is 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 on LIST.
    • ssl_tlsv1_3 is unknown to vsftpd 3.0.3 (RHEL-family 8) and makes it refuse to start with 500 OOPS: unrecognised variable in config file; remove the line there — TLS 1.2 is still enforced.
    • require_ssl_reuse=NO is 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 with implicit_ssl=YES in 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 vsftpd and /var/log/auth.log or /var/log/secure for PAM failures.
    • Brute force: vsftpd is a common target. The vsftpd jail in fail2ban is pre-defined — enable it in jail.local exactly as you would the sshd jail (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 + writable upload/ layout above; do not reach for allow_writeable_chroot=YES.
    • 530 Login incorrect — one of: wrong password; shell not in /etc/shells; user not in /etc/vsftpd.userlist (with userlist_deny=NO); user listed in /etc/vsftpd/ftpusers (RHEL's PAM deny file includes root); or on RHEL, PAM/SELinux — check journalctl -u vsftpd and ausearch -m avc -c vsftpd -ts recent.
    • Login works, LIST hangs or 425 Failed to establish connection — passive mode. The passive range is not open in the firewall/security group, or pasv_address is missing/wrong behind NAT. lftp -d shows the address the server advertised in its 227 Entering Passive Mode reply.
    • 522 SSL connection failed; session reuse required — set require_ssl_reuse=NO.
    • 500 OOPS: cannot change directory:/srv/ftp/customer1 on RHEL — SELinux: setsebool -P ftpd_full_access on, and check the directory context with ls -Z.
    • 500 OOPS: priv_sock_get_cmd — usually an upload into a directory the user cannot write, or seccomp on older kernels; try seccomp_sandbox=NO as a diagnostic only.
    • Works on the LAN, not from the internet — the router/security group forwards 21 but not 40000–40100, or pasv_address is the private IP.
    • Uploads are owned by the wrong user / unreadable by the app — local_umask=022 plus chown_uploads=YES / chown_username=www-data if another process must own the files.

    Summary

    1. Install vsftpd; open 21 and a fixed passive range in every firewall on the path.
    2. Dedicated user with /usr/sbin/nologin (added to /etc/shells), root-owned chroot home, writable upload/ subdirectory.
    3. chroot_local_user=YES, allow-list via userlist_enable + userlist_deny=NO.
    4. Explicit FTPS: ssl_enable=YES, force TLS for logins and data, TLS 1.2/1.3 only, require_ssl_reuse=NO.
    5. pasv_address = public IP when behind NAT; setsebool -P ftpd_full_access on on RHEL.
    6. Test with lftp, watch /var/log/vsftpd.log, add the fail2ban jail and an FTP uptime check.