How to Install and Configure a Secure FTP Server with vsftpd on CentOS Stream 9: SSL/TLS and Virtual Users

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

FTP server — it may sound old-school, but I still run into this requirement fairly often. My previous company had a handful of servers running CentOS 7, and when we migrated to AlmaLinux I noticed quite a few departments were still using plain FTP for internal file transfers. So I had to set everything up from scratch, and this time I went with vsftpd on CentOS Stream 9 and did it properly — with SSL/TLS and virtual users.

Comparing Approaches for Setting Up an FTP Server

Before diving in, I weighed three main options:

1. Plain FTP with System Users

The simplest approach — each FTP user is a real Linux system user. Add them to /etc/passwd and you’re done. The downsides are obvious: FTP users can SSH into the server if you don’t lock the shell. On top of that, credentials travel over the network in plaintext — not suitable for a production environment.

2. SFTP (SSH File Transfer Protocol)

Many people confuse SFTP with FTPS (FTP over SSL). SFTP runs on port 22 and uses the SSH protocol directly. It’s secure and requires no additional software if OpenSSH is already installed. However, some legacy clients or embedded devices — old NAS units, industrial PLCs — only understand plain FTP and have no SFTP support.

3. vsftpd with SSL/TLS (FTPS) + Virtual Users

This is what I chose for production. vsftpd (Very Secure FTP Daemon) is lightweight, fast, and has an impressive security track record — it was once the official FTP server for ftp.kernel.org. Combining SSL/TLS for encrypted connections with virtual users (virtual accounts, not system users) completely isolates FTP from system accounts. The setup is a bit more involved, but well worth it.

Pros and Cons Breakdown

Approach Pros Cons
Plain FTP + system users Simple, fast setup No encryption, high security risk
SFTP Good security, nothing extra to install Client must support SSH, not true FTP
vsftpd + SSL/TLS + virtual users High security, flexible, user isolation More complex setup, requires firewall + SELinux configuration

Choosing the Right Approach

For an internal environment where you control the clients (colleagues using FileZilla or WinSCP), SFTP is more than enough and far simpler. But if:

  • Clients are legacy devices that only support FTP/FTPS
  • You need multiple FTP users with different permissions without creating system accounts
  • You need an audit trail and per-user chroot into separate directories

…then vsftpd with virtual users is the right call. That’s the path I’m taking here.

Deployment Guide

Step 1: Install vsftpd

sudo dnf install -y vsftpd
sudo systemctl enable vsftpd
sudo systemctl start vsftpd

Step 2: Generate a Self-Signed SSL Certificate

For an internal environment, a self-signed cert is sufficient. For a public-facing server, use Let’s Encrypt instead.

sudo mkdir -p /etc/vsftpd/ssl
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout /etc/vsftpd/ssl/vsftpd.key \
  -out /etc/vsftpd/ssl/vsftpd.crt \
  -subj "/C=VN/ST=HCM/L=HoChiMinh/O=MyOrg/CN=ftp.example.com"

sudo chmod 600 /etc/vsftpd/ssl/vsftpd.key
sudo chmod 644 /etc/vsftpd/ssl/vsftpd.crt

Step 3: Configure vsftpd

Back up the original config file, then create a new one:

sudo cp /etc/vsftpd/vsftpd.conf /etc/vsftpd/vsftpd.conf.bak
sudo tee /etc/vsftpd/vsftpd.conf << 'EOF'
# --- Basic settings ---
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022
dirmessage_enable=YES
xferlog_enable=YES
xferlog_std_format=YES
xferlog_file=/var/log/vsftpd.log

# --- Chroot ---
chroot_local_user=YES
allow_writeable_chroot=YES

# --- Passive mode (important when behind NAT/firewall) ---
pasv_enable=YES
pasv_min_port=40000
pasv_max_port=40100
pasv_address=YOUR_SERVER_IP

# --- SSL/TLS ---
ssl_enable=YES
force_local_data_ssl=YES
force_local_logins_ssl=YES
ssl_tlsv1_2=YES
ssl_sslv2=NO
ssl_sslv3=NO
rsa_cert_file=/etc/vsftpd/ssl/vsftpd.crt
rsa_private_key_file=/etc/vsftpd/ssl/vsftpd.key

# --- Virtual users ---
guest_enable=YES
guest_username=ftpuser
virtual_use_local_privs=YES
pam_service_name=vsftpd_virtual
user_config_dir=/etc/vsftpd/vusers_conf

# --- Listen ---
listen=YES
listen_ipv6=NO
listen_port=21
EOF

Replace YOUR_SERVER_IP with the server’s actual IP address. The 40000-40100 range allows 101 simultaneous passive connections — for a small internal environment, narrowing it to 40000-40020 is perfectly fine and avoids opening too many ports on the firewall.

Step 4: Create Virtual Users with PAM + BerkeleyDB

vsftpd uses PAM to authenticate virtual users, specifically the pam_userdb module which reads a BerkeleyDB file. Install libdb-utils to get the DB creation tool. If you’re not yet familiar with DNF package management on RHEL-based systems, it’s worth a read before proceeding:

sudo dnf install -y libdb-utils

Create a user list file (format: username and password alternating line by line):

sudo tee /etc/vsftpd/vusers_passwd << 'EOF'
uploader
Strong_Password_1!
reader
Strong_Password_2!
EOF

Convert it to a BerkeleyDB file:

sudo db_load -T -t hash -f /etc/vsftpd/vusers_passwd /etc/vsftpd/vusers.db
sudo chmod 600 /etc/vsftpd/vusers.db
sudo rm /etc/vsftpd/vusers_passwd  # Remove the plaintext file after creating the DB

Create the ftpuser system user (no login shell — used only as the guest account for vsftpd):

sudo useradd -d /srv/ftp -s /sbin/nologin ftpuser
sudo mkdir -p /srv/ftp
sudo chown ftpuser:ftpuser /srv/ftp

Step 5: Configure PAM for Virtual Users

sudo tee /etc/pam.d/vsftpd_virtual << 'EOF'
auth required pam_userdb.so db=/etc/vsftpd/vusers
account required pam_userdb.so db=/etc/vsftpd/vusers
EOF

Step 6: Per-User Configuration (Optional)

One of vsftpd’s best features — each virtual user gets their own home directory and permissions, without adding any system accounts:

sudo mkdir -p /etc/vsftpd/vusers_conf

# Config for user 'uploader'
sudo tee /etc/vsftpd/vusers_conf/uploader << 'EOF'
local_root=/srv/ftp/uploader
write_enable=YES
EOF

# Config for user 'reader' (read-only)
sudo tee /etc/vsftpd/vusers_conf/reader << 'EOF'
local_root=/srv/ftp/reader
write_enable=NO
EOF

# Create directories and set ownership
sudo mkdir -p /srv/ftp/uploader /srv/ftp/reader
sudo chown ftpuser:ftpuser /srv/ftp/uploader /srv/ftp/reader

Step 7: Open the Firewall

CentOS Stream 9 uses firewalld to manage network rules. If you need a deeper dive into zones and services, the practical guide to firewalld on CentOS/RHEL covers it thoroughly.

# Open the FTP control port + passive port range
sudo firewall-cmd --permanent --add-port=21/tcp
sudo firewall-cmd --permanent --add-port=40000-40100/tcp
sudo firewall-cmd --reload

# Verify
sudo firewall-cmd --list-ports

Step 8: Configure SELinux

This is the step most people skip and then spend ages debugging. SELinux on CentOS Stream 9 runs in Enforcing mode by default — you need to set a few booleans and file contexts. Before disabling it out of frustration, it’s worth understanding what SELinux actually does and how to configure it correctly:

# Allow vsftpd to read/write virtual users' home directories
sudo setsebool -P ftp_home_dir on
sudo setsebool -P ftpd_full_access on

# If using a custom directory outside /var/ftp
sudo semanage fcontext -a -t public_content_rw_t "/srv/ftp(/.*)?" 2>/dev/null || \
  sudo chcon -R -t public_content_rw_t /srv/ftp

sudo restorecon -Rv /srv/ftp

If semanage is not available:

sudo dnf install -y policycoreutils-python-utils

Step 9: Restart and Verify

sudo systemctl restart vsftpd
sudo systemctl status vsftpd

# Check that vsftpd is listening
sudo ss -tlnp | grep :21

Testing the Connection with FileZilla

Open FileZilla and go to File → Site Manager → New Site:

  • Protocol: FTP – File Transfer Protocol
  • Encryption: Require explicit FTP over TLS
  • Host: Server IP
  • Port: 21
  • Logon Type: Normal
  • User/Password: uploader / Strong_Password_1!

On the first connection, FileZilla will prompt you to confirm the self-signed certificate — just accept it. If you see a 425 Failed to establish connection error, it’s almost certainly a passive mode issue: double-check pasv_address and make sure the firewall has the 40000-40100 port range open.

A Few Real-World Notes

There are two common issues I run into during deployment:

  1. Wrong passive mode IP: If your server sits behind NAT (common with VPS), pasv_address must be the public IP — not the private IP of the network interface. Get your public IP with curl ifconfig.me.
  2. Silent SELinux blocks: Login succeeds but file transfers hang? Run sudo ausearch -m avc -ts recent | grep vsftpd — any output means SELinux is blocking something.

Need to add a new virtual user later? Update the text file, run db_load again, create the corresponding directory, and you’re done. No need to touch system users or restart vsftpd — the daemon reads per-user config fresh for each new session.

Share: