Create the user

# A user with no shell, no home directory (the chroot replaces it)
sudo groupadd sftpusers
sudo useradd -g sftpusers -d /var/sftp/vendor-a -s /usr/sbin/nologin vendor-a

# Set a password (or use pubkey only; see below)
sudo passwd vendor-a

The chroot directory ownership rule

The unforgiving constraint: ChrootDirectory and every parent directory above it must be owned by root and not group/world writable. If the chroot is /var/sftp/vendor-a, then both /var/sftp and /var/sftp/vendor-a must be root:root with mode 755.

This means the user can't write to their own chroot root directly. The standard pattern: chroot is root-owned, and a sub-directory inside it (the user's actual writable space) is owned by them.

# Chroot directory hierarchy
sudo mkdir -p /var/sftp/vendor-a
sudo chown root:root /var/sftp /var/sftp/vendor-a
sudo chmod 755 /var/sftp /var/sftp/vendor-a

# Writable upload area for the user
sudo mkdir /var/sftp/vendor-a/uploads
sudo chown vendor-a:sftpusers /var/sftp/vendor-a/uploads
sudo chmod 755 /var/sftp/vendor-a/uploads

# Read-only download area
sudo mkdir /var/sftp/vendor-a/downloads
sudo chown root:sftpusers /var/sftp/vendor-a/downloads
sudo chmod 750 /var/sftp/vendor-a/downloads

sshd_config

Add to /etc/ssh/sshd_config (or a drop-in under /etc/ssh/sshd_config.d/):

# At the bottom of the file (Match blocks must come last)

Subsystem sftp internal-sftp

Match Group sftpusers
    ChrootDirectory %h            # %h = home dir = /var/sftp/vendor-a
    ForceCommand internal-sftp
    AllowTcpForwarding no
    AllowAgentForwarding no
    X11Forwarding no
    PermitTTY no
    PermitTunnel no
    GatewayPorts no

Key directives:

  • Subsystem sftp internal-sftp — use the in-process SFTP server (no external sftp-server binary in the chroot needed).
  • ChrootDirectory %h — chroots to the user's home directory.
  • ForceCommand internal-sftp — even if the user manages to request a shell, they get SFTP.
  • AllowTcpForwarding no + the rest — close off other SSH-feature escape hatches.
# Validate and reload
sudo sshd -t
sudo systemctl reload ssh

Test it

# From a client
sftp vendor-a@server.example.com
# Connecting to server.example.com...
# vendor-a@server.example.com's password: ...
# sftp> pwd
# Remote working directory: /
# sftp> ls
# downloads  uploads
# sftp> cd uploads
# sftp> put localfile.txt
# Uploading localfile.txt to /uploads/localfile.txt
# sftp> cd /etc        # Try to escape the chroot
# Couldn't canonicalize: No such file or directory   <-- contained

# Try SSH command execution — refused
ssh vendor-a@server.example.com 'ls -la'
# This service allows sftp connections only.

Pubkey-only (recommended)

Passwords on file-transfer-only accounts are not great; pubkeys are better. The catch with chrooted users: ~/.ssh/authorized_keys inside the chroot must follow the same root-owned-parents rule.

Two paths:

  1. AuthorizedKeysFile outside the chroot — store keys in a system-managed location:
    # In sshd_config
    AuthorizedKeysFile /etc/ssh/authorized_keys/%u
    
    # Then per user
    sudo mkdir -p /etc/ssh/authorized_keys
    sudo chown root:root /etc/ssh/authorized_keys
    sudo chmod 755 /etc/ssh/authorized_keys
    echo "ssh-ed25519 AAAA... vendor-a-laptop" | \
        sudo tee /etc/ssh/authorized_keys/vendor-a
    sudo chmod 644 /etc/ssh/authorized_keys/vendor-a
    sudo chown root:root /etc/ssh/authorized_keys/vendor-a
    This is the cleanest pattern — admin-managed keys, no chroot-mount headache.
  2. Conventional ~/.ssh/authorized_keys inside the chroot — create root-owned .ssh subdirectory and write the keys file (owned by the user); permission carefully:
    sudo mkdir /var/sftp/vendor-a/.ssh
    sudo chown root:root /var/sftp/vendor-a/.ssh
    sudo chmod 755 /var/sftp/vendor-a/.ssh
    
    # But sshd inside the chroot is reading authorized_keys as the user
    # So the file inside must be owned by the user
    echo "ssh-ed25519 ..." > /tmp/keys
    sudo install -m 600 -o vendor-a -g sftpusers /tmp/keys /var/sftp/vendor-a/.ssh/authorized_keys
    Works but more fragile; (1) is preferred.

Once pubkey auth works, disable password auth for these users:

# In sshd_config under the Match Group block
PasswordAuthentication no
PubkeyAuthentication yes

Useful additions

Quotas: limit disk use per user

# Install quotas
sudo apt install quota
# Enable user quotas on the relevant filesystem in /etc/fstab:
#   /dev/sdb1 /var/sftp ext4 defaults,usrquota 0 2
sudo mount -o remount /var/sftp

sudo quotacheck -cum /var/sftp
sudo quotaon /var/sftp

# Set per-user limit (1 GB soft, 1.5 GB hard)
sudo setquota -u vendor-a 1000000 1500000 0 0 /var/sftp

Rate-limit transfers

Use the -l option in internal-sftp (logs only); for bandwidth limits, wrap in tc or use scp's -l from the client.

fail2ban for failed logins

CrowdSec (see that tutorial) or fail2ban with the sshd jail catch brute-force on the SFTP-only account too — same as regular SSH.

One-shot upload accounts

For "this vendor delivers one file then never logs in again," combine with ChrootDirectory + a OnSession hook that disables the account after success:

# /usr/local/bin/disable-after-upload.sh
#!/bin/bash
# called from a pam_exec line; disable account after the SFTP session ends
sudo usermod -L "$PAM_USER"

More complex than needed for most use cases — usually a periodic cron rotation of keys is cleaner.

Worth knowing

  • Logs. Set LogLevel VERBOSE in sshd_config; journalctl -u ssh -f shows every SFTP file operation with timestamps + user.
  • Don't put the chroot on /home. The default user-skeleton, login scripts, and other surprises in /home parents make the "root-owned + 755" rule annoying. /var/sftp/<user> is the canonical location.
  • Test with a regular SSH user first to confirm the server-side config doesn't break general SSH. Match Group sftpusers only affects the group; regular users keep their shells.
  • For 100+ third-party users, a dedicated SFTP appliance (Files.com, MFT solutions, or open-source like sftpgo) is more operational. For 1-10 users, plain OpenSSH is right.