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.
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.
dnf install vsftpd
systemctl enable --now vsftpdIn
/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.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/uploadsFix 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.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.virtualWith
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.
├── 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
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.
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=YESforce_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.
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.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-sftpinternal-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/uploadsNote 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-aOr 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 yesChrootDirectory /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.
chroot_local_user=YES; root not writable by the user, or allow_writeable_chroot=YES knowinglyuser_config_dir + local_root per user fileanonymous_enable=NOforce_local_logins_ssl and force_local_data_ssl, real certificateChrootDirectory + ForceCommand internal-sftp, port 22, no second daemonSubsystem sftp internal-sftp; Match Group sftponly with ChrootDirectory /srv/sftp/%u, ForceCommand internal-sftp, AllowTcpForwarding no, X11Forwarding no; Match blocks last in the filesshd -t before restart, test with sftp from a second sessionCommands 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.
