FTP Server with User Jails for Multi-Tenancy: vsftpd Done Properly (and When to Use SFTP Instead)

root@rhel:~# systemctl status vsftpd
root@rhel:~# systemctl status vsftpd # running, jailed, and not answering for anyone it shouldn’t

FTP Server with User Jails for Multi-Tenancy
vsftpd Done Properly (and When to Use SFTP Instead)

FTP is the protocol everyone assumes is dead, until the week it is not — a vendor who only speaks FTP, a legacy integration that will not be rewritten, a customer who needs to drop files on your server and nothing else. Multi-tenancy is where it gets confusing: one server, many tenants, each one jailed to their own directory, none of them able to see the others, and the whole thing secured properly instead of the way it was done in 2003. This is that setup, with vsftpd on RHEL/AlmaLinux, and the honest answer at the end about when to use SFTP instead.

# first, the honest question — do you need FTP at all
Plain FTP sends usernames, passwords, and data in cleartext. Anyone on the path between the client and the server can read all three, and that is not a theoretical risk — it is the protocol working as designed, in 2026, on a network that is not yours. If the other end can speak SFTP, use SFTP: you already have it if you have SSH, it is encrypted end to end, it needs one port (22) instead of a range, and the jail story is cleaner (see the last section). The rest of this post is for the case where the other end cannot — a vendor, a legacy system, a customer whose tooling only knows FTP — and you have to make FTP as safe as FTP can be made.
The second honest question is FTPS vs FTP. FTPS is FTP with TLS, and it is the minimum for any FTP that carries credentials over a network you do not control. If the other end supports FTPS, use it — the configuration is a few lines, and the alternative is cleartext passwords. If the other end supports neither FTPS nor SFTP, you are left with plain FTP behind a firewall, on a network you control, with strong passwords and short-lived accounts — and you should say so, in writing, to whoever asked for it, because that risk belongs to the requester, not to you.
# the base: vsftpd, locked down — the defaults you change first
Install and enable it, then change the defaults before anyone connects:
dnf install vsftpd
systemctl enable --now vsftpd

In /etc/vsftpd/vsftpd.conf, the lines that matter:
anonymous_enable=NO — no anonymous access, ever, on a multi-tenant server. Anonymous FTP is a file drop for the internet, and it is how a server ends up hosting someone else’s files.
local_enable=YES — local users can log in. (Virtual users, below, are the better answer for tenants — but start here, and understand the local-user behaviour first.)
write_enable=YES — without this, nobody can upload anything, and the server is read-only. Set it deliberately, not by accident.
chroot_local_user=YES — the jail. A logged-in user cannot leave their home directory. This is the line the whole post is about, and the next section is about the trap it sets.
allow_writeable_chroot=YES — see below. On RHEL, without this, vsftpd refuses to let a user into a writable chroot, and the refusal is the error everyone hits.
pasv_min_port=10000
pasv_max_port=10100
— the passive port range, fixed and narrow, so the firewall can allow exactly that range and nothing else. The default is a wide ephemeral range, and a wide range is a firewall rule you cannot write.
SELinux, because this is RHEL: vsftpd runs confined, and the booleans decide what it may do. getsebool -a | grep ftp shows them; setsebool -P ftpd_full_access on is the broad answer when the jail’s directories live outside the default locations, and allow_ftpd_anon_write stays off. If vsftpd can read a directory in the shell but not serve it, SELinux is the first suspect — check /var/log/audit/audit.log for denials before you start chmodding things, because the fix is a boolean or a context, not a permission.
# the jail, and the writable-root trap — the error everyone hits
The trap: with chroot_local_user=YES, vsftpd refuses to chroot into a directory that is writable by the user — a security measure against a user replacing the jail’s contents. The error in the log is “500 OOPS: vsftpd: refusing to run with writable root inside chroot()”, and the first time you see it, it reads like a bug. It is not a bug. It is vsftpd telling you the jail is not safe as configured.
The two fixes, and which to choose:
Fix one — a non-writable root with a writable subdirectory (the clean fix). The user’s home (the chroot root) is owned by root and not writable by the user; inside it, a subdirectory (uploads, files, incoming) is owned by the user and writable. The user lands in the root, sees the subdirectory, and works in it. This is the layout to use when you can choose the layout.
mkdir -p /srv/ftp/tenant-a/uploads
chown root:root /srv/ftp/tenant-a
chmod 555 /srv/ftp/tenant-a
chown tenant-a:tenant-a /srv/ftp/tenant-a/uploads
chmod 755 /srv/ftp/tenant-a/uploads

Fix two — allow_writeable_chroot=YES (the pragmatic fix). One line in vsftpd.conf, and the writable root is allowed. It weakens the protection the refusal exists to provide, and on a multi-tenant server you should prefer fix one — but when the tenant’s tooling expects to write at the root of its jail, or when you are retrofitting an existing layout, it is the fix that ships. If you use it, say so in the configuration, and do not also make the root world-writable.
Per-user roots: a tenant’s jail does not have to be their home directory. user_config_dir=/etc/vsftpd/user_conf lets you set a local_root per user, in a file named for the user — so tenant-a lands in /srv/ftp/tenant-a, tenant-b in /srv/ftp/tenant-b, and neither can see the other, or the path above their own root. That is the multi-tenancy mechanism: one server, one daemon, one jail per tenant, enforced by the daemon, not by trust.
The writable-root refusal is vsftpd doing its job. The admin who disables it without understanding it has not fixed the problem — they have removed the check that would have caught the next problem.
# multi-tenancy: virtual users — tenants are not system users
The problem with local users as tenants: every tenant is a system account, with a shell, a home directory, and a password in /etc/shadow — and a tenant who can log in by FTP can, in principle, log in by other means unless you take the shell away. For a handful of tenants that is manageable. For many tenants, or for tenants who come and go, it is not.
Virtual users are the answer. The tenants exist only in a database (a Berkeley DB file, built with db_load from a text list of usernames and passwords), and they all map to a single system guest account that owns the files. vsftpd authenticates against the database via PAM (pam_userdb), and the guest account never logs in itself. Adding a tenant is adding a line to a file and rebuilding the database; removing a tenant is removing the line. No system accounts, no shells, no /etc/shadow entries for customers.
guest_enable=YES
guest_username=ftpvirt
virtual_use_local_privs=YES
pam_service_name=vsftpd.virtual

With user_config_dir from the previous section, each virtual user still gets their own local_root and their own jail — the tenancy is per virtual user, and the system account is just the file owner underneath. The guest account’s shell is /sbin/nologin, and it owns nothing outside the FTP tree.
Per-tenant rules, in the same place: a file per user in the user_config_dir can set upload and download permissions, speed limits, and idle timeouts per tenant — a tenant who should only download, a tenant who should only upload, a tenant on a slow link. The configuration is per tenant because the jail is per tenant, and the two live in the same directory, which is the only way this stays manageable at scale.
Quotas, honestly: vsftpd has no built-in per-user disk quota. Enforce it at the filesystem level — a separate filesystem or a directory quota per tenant’s jail — or at the process level with a wrapper that checks usage on login. A tenant who can fill the disk can take down every other tenant on the box, and the fix is not in vsftpd’s configuration. It is in the filesystem underneath it.
# the directory structure, as a diagram — who owns what, and who can write where
The whole tenancy model is a directory tree, and the ownership is the security. Read it top to bottom: root owns everything a tenant lands in, the tenant owns only the subdirectory they work in, and no tenant’s tree touches another’s. The FTP side and the SFTP side are the same shape, on purpose — one layout to learn, two protocols that use it.
/srv/ftp/ root:root 755
├── tenant-a/ root:root 555 — the jail root, not writable by tenant-a
│ ├── uploads/ tenant-a:tenant-a 755 — writable, this is where tenant-a works
│ └── downloads/ tenant-a:tenant-a 755 — read-only files you place for tenant-a
├── tenant-b/ root:root 555 — tenant-b’s jail, invisible to tenant-a
│ └── uploads/ tenant-b:tenant-b 755
└── shared/ root:root 555 — only if a tenant genuinely needs a shared drop; usually omit
 
/srv/sftp/ root:root 755 — every parent must be root-owned, sshd checks the whole path
├── tenant-a/ root:root 755 — the chroot root, not writable by tenant-a
│ ├── uploads/ tenant-a:sftponly 755 — writable
│ └── .ssh/ root:root 700 — authorized_keys lives here, inside the chroot
│ └── authorized_keys root:root 600
└── tenant-b/ root:root 755
└── uploads/ tenant-b:sftponly 755
Two rules, visible in the diagram: a tenant never writes at the root of their own jail, and a tenant never sees a path that leads to another tenant. If a directory in the tree breaks either rule, the tree is wrong — fix the tree, not the daemon’s configuration.
# passive mode and the firewall — the part that breaks every first setup
FTP uses two connections: a control connection on port 21, and a data connection on a different port for every transfer. In active mode the server connects back to the client, which every modern firewall and NAT setup will block. In passive mode the client connects to the server on a port the server chooses — and that port is the one you have to allow through the firewall.
That is why the passive range is fixed and narrow (pasv_min_port / pasv_max_port, 10000–10100 above). The firewall rules are then exactly three: port 21/tcp, the passive range, and — for FTPS — the same, because FTPS uses the same ports. On firewalld: firewall-cmd --permanent --add-service=ftp for port 21, and a rich rule or a direct port range for the passive ports. On the firewalld guide’s terms, the passive range is a port range with a protocol, not a single port — and the range must match the vsftpd configuration exactly, or transfers will connect and then hang, which is the classic symptom: login works, listing fails.
The client side of the same story: a client behind NAT must use passive mode (every modern client defaults to it), and a client that insists on active mode will fail through any firewall that is doing its job. When a tenant says “I can log in but I cannot list files”, the answer is almost always passive mode or the passive port range — not the username, not the password, and not the jail.
# FTPS: the encryption — a few lines, and the only way FTP should cross a network
In vsftpd.conf:
ssl_enable=YES
rsa_cert_file=/etc/ssl/certs/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.key
force_local_logins_ssl=YES
force_local_data_ssl=YES

force_local_logins_ssl means credentials never cross the wire in cleartext; force_local_data_ssl means the files do not either. Both, not one — encrypting the login and then sending the files in clear is a fix that protects the password and exposes the data. Use a real certificate (Let’s Encrypt, or the organisation’s CA), not a self-signed one the tenants have to click past, because a certificate warning the tenants learn to ignore is a warning that will not protect them when it matters.
Explicit vs implicit FTPS: explicit (port 21, TLS negotiated after connect) is the standard, and the one every modern client supports. Implicit (port 990, TLS from the first byte) is the legacy mode, and some older clients still need it. Support explicit, and add implicit only if a specific client demands it — and document which clients those are, so the extra port does not outlive the client that needed it.
# the client side, properly — what to tell the tenant
The tenant’s instructions should be three lines, not a manual. Host, username, password — and the mode: FTPS explicit, passive mode on. In FileZilla that is the protocol dropdown (FTPES — FTP over explicit TLS) and the transfer mode (passive). In WinSCP it is the same choice, under the FTP protocol. On the command line, lftp with set ftp:ssl-force true and set ftp:ssl-protect-data true does the same thing, and lftp is the client to recommend for scripting, because it retries, resumes, and mirrors.
What the tenant should never do: save the password in a client that stores it in cleartext on a shared machine, or use plain FTP “just this once” because FTPS failed. A failed FTPS connection is a configuration problem to report, not a reason to fall back to cleartext — and a client that silently falls back is a client to replace.
Logging, so you know which tenant did what: xferlog_enable=YES and vsftpd_log_file=/var/log/vsftpd.log (or the dual log) give you a transfer log with the username, the file, the direction, and the time. On a multi-tenant server that log is the audit trail, and it is also how you answer the tenant who says “I uploaded it” — check the log, and the log will tell you whether they did, when, and to which directory.
# when to use SFTP instead — the honest ending, with the full sshd_config
If the other end can speak SFTP, use SFTP. It is SSH’s file transfer protocol, it runs over port 22, it is encrypted by definition, it needs no passive port range, and its jail story is cleaner: no second daemon, no second port range, no certificate to manage. The jail is built in sshd_config, and this is the whole configuration — do not skip the directory setup, because the jail will refuse to work without it, silently, in the same way vsftpd’s writable root refuses.
1. The subsystem, in /etc/ssh/sshd_config. Most distributions already ship this line; check it is present and not commented out, and that no other Subsystem sftp line overrides it:
Subsystem sftp internal-sftp
internal-sftp is the in-process SFTP server — no external binary, no shell, and it works inside a chroot where the external /usr/libexec/openssh/sftp-server binary would not be available. If the subsystem still points at the external binary, change it. A chrooted user cannot execute a binary that lives outside the chroot.
2. The directory layout, before any Match block. The chroot directory — and every directory above it in the path — must be owned by root and not writable by anyone else, or sshd will refuse the login with “fatal: bad ownership or modes for chroot directory”. That is the same writable-root rule as vsftpd, for the same reason, and it is the error everyone hits on their first SFTP jail. The layout is the same as the vsftpd one: a root-owned, non-writable chroot root, with a user-owned writable subdirectory inside it:
mkdir -p /srv/sftp/tenant-a/uploads
chown root:root /srv/sftp /srv/sftp/tenant-a
chmod 755 /srv/sftp /srv/sftp/tenant-a
chown tenant-a:sftponly /srv/sftp/tenant-a/uploads
chmod 755 /srv/sftp/tenant-a/uploads

Note the parent: /srv/sftp itself must also be root-owned and non-writable, because sshd checks the whole path, not just the chroot directory. A writable parent breaks the jail exactly as surely as a writable root does.
3. The user, shell-less. The tenant is a system user with no shell and no home outside the jail — their “home” is the chroot, and they never get a prompt:
groupadd sftponly
useradd -g sftponly -s /sbin/nologin -d /srv/sftp/tenant-a tenant-a
passwd tenant-a

Or key-only, which is the better answer: put the tenant’s public key in /srv/sftp/tenant-a/.ssh/authorized_keys (owned by root, inside the chroot, with the .ssh directory owned by root — sshd reads it from the chroot path, and the same ownership rules apply), and disable password authentication for the group.
4. The Match block, at the end of sshd_config. Match blocks must come last — anything after a Match block belongs to that block, and a global setting placed after it will silently become a per-user setting. The block, per tenant, or per group for all tenants at once:
Match Group sftponly
  ChrootDirectory /srv/sftp/%u
  ForceCommand internal-sftp
  AllowTcpForwarding no
  X11Forwarding no
  PasswordAuthentication yes

ChrootDirectory /srv/sftp/%u uses the username token, so one block jails every tenant in the group to their own directory — tenant-a to /srv/sftp/tenant-a, tenant-b to /srv/sftp/tenant-b — with no per-user block to maintain. ForceCommand internal-sftp is what makes the user shell-less even if their shell were ever set to something else: whatever they ask for, they get SFTP and nothing else. AllowTcpForwarding no and X11Forwarding no close the two forwarding channels a file-transfer user has no business using — a jailed SFTP user who can forward TCP is a jailed user with a tunnel out. Set PasswordAuthentication per group here if the tenants use keys and the rest of the box uses passwords, or the reverse.
5. Validate, restart, and test — in that order.
sshd -t — validates the configuration before you restart the daemon. A syntax error in a Match block will fail the restart, and a failed sshd restart on a remote box is how you lock yourself out.
systemctl restart sshd — and verify it came back: systemctl status sshd, and an SSH connection from a second session before you close the first.
sftp tenant-a@localhost — test from the box itself first: you should land in the chroot root, see only the uploads directory, and be unable to cd /etc or cd .. out of it. Then test from the tenant’s network, with their client, the way they will actually use it.
6. The failures, and what they mean. “Connection closed” or “bad ownership or modes for chroot directory” — the directory ownership above, check every level of the path. “Permission denied (publickey)” — the authorized_keys file is inside the chroot, so its ownership and permissions are checked against the chroot path, and a .ssh directory owned by the user instead of root is the usual cause. A user who can log in but sees an empty directory — the uploads subdirectory is missing, or the user’s home in /etc/passwd does not match the chroot path. And a user who gets a shell instead of SFTP — the Match block is not matching (check the group, check the block is at the end of the file, and check sshd -T -C user=tenant-a, which prints the effective configuration for that user and shows you exactly which Match blocks applied).
The rule of thumb: SFTP for anyone who can use it, FTPS for the vendor or legacy system that cannot, plain FTP for nobody — and if someone insists on plain FTP, the insistence itself is the problem to escalate, not a configuration to accommodate.
FTP with jails, done properly, is a safe and useful service. FTP with jails, done carelessly, is a cleartext password collector with a directory listing. The difference is not the protocol. It is the checklist — and for SFTP, the checklist lives in sshd_config, in the directory permissions, and in the test you run before you hand the account to the tenant.
# the cheat sheet — keep this, not the man page
jail
chroot_local_user=YES; root not writable by the user, or allow_writeable_chroot=YES knowingly
per-tenant root
user_config_dir + local_root per user file
tenants are not system users
virtual users via pam_userdb, one guest account, no shells
no anonymous
anonymous_enable=NO
passive ports
fixed narrow range in vsftpd.conf, same range in the firewall; login works + listing fails = passive ports
encryption
FTPS explicit, force_local_logins_ssl and force_local_data_ssl, real certificate
client
passive mode, FTPES, lftp for scripting; never fall back to plain FTP
better answer
SFTP with ChrootDirectory + ForceCommand internal-sftp, port 22, no second daemon
sshd_config for SFTP jails
Subsystem sftp internal-sftp; Match Group sftponly with ChrootDirectory /srv/sftp/%u, ForceCommand internal-sftp, AllowTcpForwarding no, X11Forwarding no; Match blocks last in the file
SFTP jail directories
chroot root and every parent root-owned, non-writable; writable subdirectory inside, owned by the tenant; sshd -t before restart, test with sftp from a second session

Commands verified against vsftpd and OpenSSH documentation and RHEL 9/10 behaviour, November 7, 2026. vsftpd option names and SELinux booleans vary by version — check the installed vsftpd.conf man page on the box in front of you before applying. Example paths and usernames are illustrative — substitute your own tenant names and directories.

Scroll to Top