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:
- Wrong passive mode IP: If your server sits behind NAT (common with VPS),
pasv_addressmust be the public IP — not the private IP of the network interface. Get your public IP withcurl ifconfig.me. - 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.

