V3
Storage AWS

vsftpd 3.0.5 on AWS User Guide

| Product: vsftpd 3.0.5 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the vsftpd 3.0.5 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg File Sharing and Sync listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

vsftpd (the "Very Secure FTP Daemon") is a small, fast FTP server with a long security record. Use it when your partners, devices or older systems speak FTP: scanners and cameras that upload over FTP, line of business systems that drop files every night, or people who use FileZilla, WinSCP or Cyberduck. A stock vsftpd install accepts plain FTP logins from every Linux account on the machine. The cloudimg image changes that:

  • FTPS only. Explicit FTP over TLS (AUTH TLS on port 21) is required for the login and for every transfer and directory listing. A client that tries plain FTP with a real user name is refused before it can send a password, and a client that encrypts the login but not the data is refused too. Only TLS 1.2 with ECDHE key exchange and AEAD ciphers (AES-GCM, ChaCha20-Poly1305) is accepted; Step 15 explains why TLS 1.3 is switched off for this server.
  • A user, password and certificate unique to your instance. First boot creates the FTP user ftpuser with a random 24 character password and a TLS certificate for your instance, and writes them to /root/vsftpd-credentials.txt (readable by root only). The system stores only a yescrypt hash of the password. No password or key is baked into the image.
  • Only FTP users can log in, and they can never use SSH. Anonymous FTP is off. A login must belong to the vsftpd-users group, and root, ubuntu and every system account are on a deny list. FTP users have no shell, and SSH refuses the whole vsftpd-users group, so an FTP password never opens a shell or a tunnel.
  • Everyone in a jail. Each user is locked (chrooted) into its own directory, /srv/vsftp/<user>, and uploads into /files inside it. Every user works as its own Linux account, so users cannot read each other's files. The jail lives on a dedicated 20 GiB EBS data volume, mounted nosuid,nodev,noexec so an upload can never be run as a program.
  • Passive mode that works out of the box on EC2. Passive data ports are fixed to 40000-40099, and the public address announced to clients is read from instance metadata (IMDSv2) on every boot, so it stays right after a stop and start. Active mode is off, so the server never connects out to addresses a client names.
  • Login limits. Three failed logins end a session, every failure costs three seconds, one address may hold at most 10 connections and the server at most 50.
  • Closed until first boot, and fail closed. The image holds no FTP user and no certificate key, so the server cannot start. First boot creates both, starts the server, proves a full encrypted round trip (including five 1 MiB uploads) and checks that no password leaked into a log; if anything fails, the server is stopped again.
  • Hardened service. The server runs in a systemd sandbox that can write only to /srv/vsftp, it is blocked from the instance metadata service, and the AMI requires IMDSv2.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What's on the image

  • vsftpd 3.0.5, Ubuntu's own package vsftpd 3.0.5-0ubuntu3.1 from the noble-updates main archive, installed unmodified with apt. 3.0.5 is the newest vsftpd release. Because it is the distribution package, Ubuntu's security updates for vsftpd and OpenSSL arrive through apt and unattended-upgrades (Step 16).
  • cloudimg adds only configuration, the PAM policy, the systemd units and the account and maintenance tools in /usr/local/sbin (vsftpd-*, Step 10).
  • Ubuntu 24.04 LTS, OpenSSL 3, cloud-init and the AWS Systems Manager agent. Every package the image adds comes from Ubuntu's main archive.

Default user: ubuntu · Architecture: x86_64 · Recommended instance type: m5.large

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair and a VPC subnet with a route to the internet (or a private route you use for management).
  • A security group that allows, from your own networks and your FTP users' addresses only:
  • TCP 22 for SSH administration,
  • TCP 21 for the FTPS control connection,
  • TCP 40000-40099 for passive data connections (the exact range the server uses).
  • An FTPS client: FileZilla, WinSCP, Cyberduck, lftp or curl.

Do not open ports 21 and 40000-40099 to 0.0.0.0/0 unless the server really must accept uploads from anywhere on the internet. If you do, expect constant password guessing from scanners: the per instance password and the login limits keep them out, but every attempt still costs you log noise and connections, and this image has no automatic ban (Step 9). List the address ranges your users and systems connect from instead.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick vsftpd 3.0.5 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (FTP needs little CPU; size for your users and transfer volume), your key pair, and a security group as above. The AMI carries two volumes: the 8 GiB root volume and a 20 GiB gp3 data volume (/dev/sdf) for /srv/vsftp. You can make the data volume larger at launch; every boot grows the file system to fill it.

With the AWS CLI (replace the values in angle brackets; the second mapping sets the data volume size):

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.large --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --block-device-mappings 'DeviceName=/dev/sdf,Ebs={VolumeSize=100,VolumeType=gp3}' \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=vsftpd}]'

To add the FTPS rules to an existing security group for one client network (here the documentation range 203.0.113.0/24; use your own):

aws ec2 authorize-security-group-ingress --group-id <your-security-group> \
  --ip-permissions 'IpProtocol=tcp,FromPort=21,ToPort=21,IpRanges=[{CidrIp=203.0.113.0/24}]' \
                   'IpProtocol=tcp,FromPort=40000,ToPort=40099,IpRanges=[{CidrIp=203.0.113.0/24}]'

Both rules are needed. With only port 21 open, clients log in but every listing and transfer hangs, because the data connection to a port in 40000-40099 is dropped.

Step 2: Connect to your instance

Version SSH user
vsftpd 3.0.5 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Port 22 is for you, the administrator, with your EC2 key pair (password logins are off). It is not an SFTP service for your FTP users: SSH refuses every member of the vsftpd-users group (Step 8). If your users need SFTP rather than FTPS, choose an SFTP server version from the same listing.

Step 3: Check the services

First boot creates the user and the certificate, starts the server and proves an encrypted round trip before it finishes (usually under a minute):

systemctl is-active vsftpd vsftpd-prepare vsftpd-firstboot
sudo test -f /var/lib/cloudimg/vsftpd-firstboot.done && echo FIRSTBOOT_DONE
sudo vsftpd-healthcheck
active
active
active
FIRSTBOOT_DONE
PRIMARY_HEALTH_OK vsftpd answers on :21, offers AUTH TLS and refuses cleartext logins (530)

Only SSH and vsftpd listen on public addresses. vsftpd listens on IPv4 only, and a passive data port opens only for the duration of a transfer:

sudo ss -lntpH | awk '{split($6,p,"\""); print $4, p[2]}' | grep -v '127.0.0' | sort
0.0.0.0:21 vsftpd
0.0.0.0:22 sshd
[::]:22 sshd

Step 4: Get your user and password

The credentials file is readable by root only. This shows everything in it except the password:

sudo grep -E '^(FTP_HOST|FTP_PORT|FTP_USER|FTP_TLS|FTP_PASSIVE_PORTS|FTP_PASSIVE_ADDRESS|FTP_UPLOAD_DIR|FTP_HOME|TLS_CERT_SHA256)=' /root/vsftpd-credentials.txt
sudo stat -c '%a %U %n' /root/vsftpd-credentials.txt
FTP_HOST=ec2-<public-ip-dashed>.compute-1.amazonaws.com
FTP_PORT=21
FTP_USER=ftpuser
FTP_TLS=explicit FTPS (AUTH TLS) required for login and data
FTP_PASSIVE_PORTS=40000-40099
FTP_PASSIVE_ADDRESS=<public-ip>
FTP_UPLOAD_DIR=/files
FTP_HOME=/srv/vsftp/ftpuser
TLS_CERT_SHA256=<certificate-sha256-fingerprint>
600 root /root/vsftpd-credentials.txt

To print the password itself (do this where nobody can read your screen, and do not paste it into tickets or chat):

sudo grep '^FTP_PASSWORD=' /root/vsftpd-credentials.txt | cut -d= -f2-

FTP_HOST is the instance's public DNS name from EC2 instance metadata (the public or private IP address when the subnet has no public DNS names). The certificate also names the public and private IP addresses, so connecting by IP address works too. FTP_PASSIVE_ADDRESS is the address the server announces for passive transfers (Step 11). To use your own DNS name, see Step 12. To give the password a new value, see Step 10.

Step 5: Connect with an FTPS client

In FileZilla, open File > Site Manager > New site and set:

Field Value
Protocol FTP - File Transfer Protocol
Host FTP_HOST from Step 4, port 21
Encryption Require explicit FTP over TLS
Logon type Normal (or Ask for password)
User / Password ftpuser and the password from Step 4

On the Transfer Settings tab choose Passive mode (active mode is refused by the server). After you connect you are in your jail, which holds one directory, files: open it and upload there (the jail's top level is read only). The first time you connect, FileZilla shows the server's certificate: it is self signed and was created on your instance, so compare its SHA-256 fingerprint with TLS_CERT_SHA256 from Step 4 before you tick Always trust this certificate. Do not accept a certificate whose fingerprint does not match. You can print the fingerprint on the instance at any time:

sudo openssl x509 -noout -subject -fingerprint -sha256 -ext subjectAltName,basicConstraints -in /etc/vsftpd/tls/vsftpd.crt
subject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = cloudimg
sha256 Fingerprint=<certificate-sha256-fingerprint>
X509v3 Subject Alternative Name: 
    DNS:ec2-<public-ip-dashed>.compute-1.amazonaws.com, IP Address:<public-ip>, IP Address:<private-ip>, IP Address:127.0.0.1
X509v3 Basic Constraints: critical
    CA:FALSE

The certificate is a server certificate only (CA:FALSE): trusting it lets your client trust this one server, nothing else.

WinSCP: File protocol FTP, Encryption TLS/SSL Explicit encryption. Cyberduck: FTP-SSL (Explicit AUTH TLS).

From a terminal on your own computer, first save the server's certificate and check its fingerprint against TLS_CERT_SHA256, then let curl verify the server with it. --ssl-reqd makes curl refuse to continue without TLS, and curl asks for the password when you give only the user name:

openssl s_client -starttls ftp -connect <FTP_HOST>:21 </dev/null 2>/dev/null | openssl x509 > vsftpd.crt
openssl x509 -noout -subject -fingerprint -sha256 -in vsftpd.crt
curl --ssl-reqd --cacert vsftpd.crt -u ftpuser -T ./report.txt ftp://<FTP_HOST>/files/report.txt
curl --ssl-reqd --cacert vsftpd.crt -u ftpuser --list-only ftp://<FTP_HOST>/files/
curl --ssl-reqd --cacert vsftpd.crt -u ftpuser ftp://<FTP_HOST>/files/report.txt
curl --ssl-reqd --cacert vsftpd.crt -u ftpuser -Q 'DELE files/report.txt' -o /dev/null ftp://<FTP_HOST>/

Run from a computer outside AWS against a test instance (Ubuntu 24.04's curl 8.5.0 with OpenSSL 3.0), this printed the fingerprint, the listing and the downloaded file:

subject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = cloudimg
sha256 Fingerprint=<certificate-sha256-fingerprint>
report.txt
hello from my laptop

The curl that ships with macOS works too (it was tested with the same commands). In scripts, put user = "ftpuser:<password>" in a file only you can read and pass it with curl -K <file> instead of -u, so the password never appears in your process list or shell history.

With lftp, force TLS on the login and the data, and trust the saved certificate:

lftp -u ftpuser <FTP_HOST>
lftp ftpuser@<FTP_HOST>:~> set ftp:ssl-force true
lftp ftpuser@<FTP_HOST>:~> set ftp:ssl-protect-data true
lftp ftpuser@<FTP_HOST>:~> set ssl:ca-file vsftpd.crt
lftp ftpuser@<FTP_HOST>:~> put lftp.txt -o files/lftp.txt
lftp ftpuser@<FTP_HOST>:~> ls files
-rw-r-----    1 ftp      ftp            16 Oct 31 20:16 lftp.txt

The listing shows ftp as the owner of every file on purpose (hide_ids=YES): clients never learn the Linux account names on the server.

Step 6: Upload and download from the instance

This uploads a file into /files, reads it back, lists the directory, deletes the file and then shows that the jail's top level is read only, all over FTPS to the instance's own private address, checking the server certificate. The password is passed to curl on standard input, so it never appears in the process list, your shell history or the output:

sudo bash -c '
  F=/root/vsftpd-credentials.txt
  U=$(grep "^FTP_USER=" $F | cut -d= -f2-); P=$(grep "^FTP_PASSWORD=" $F | cut -d= -f2-)
  IP=$(hostname -I | cut -d" " -f1)
  ftps() { printf "user = \"%s:%s\"\n" "$U" "$P" | curl -K - -sS --ssl-reqd --cacert /etc/vsftpd/tls/vsftpd.crt "$@"; }
  echo "hello from cloudimg" > /run/report.txt
  ftps -T /run/report.txt "ftp://$IP/files/report.txt" && echo "upload   -> ok"
  echo "download -> $(ftps "ftp://$IP/files/report.txt")"
  echo "list     -> $(ftps --list-only "ftp://$IP/files/" | tr "\n" " ")"
  ftps -Q "DELE files/report.txt" -o /dev/null "ftp://$IP/" && echo "delete   -> ok"
  ftps -T /run/report.txt "ftp://$IP/report.txt" 2>&1 || echo "upload to the jail root refused (curl rc $?)"
  rm -f /run/report.txt
'
upload   -> ok
download -> hello from cloudimg
list     -> report.txt 
delete   -> ok
curl: (25) Failed FTP upload: 553
upload to the jail root refused (curl rc 25)

Uploaded files land in /srv/vsftp/ftpuser/files, owned by the ftpuser account with mode 0640. To drop files in for a user over SSH, copy them there and give them to that user (sudo chown ftpuser:ftpuser /srv/vsftp/ftpuser/files/<file>).

Step 7: Run the built in check

The image ships an end to end check you can run at any time; first boot runs it too. It reads the credentials file itself, passes the password to curl in a root only temporary file, and drives the server over FTPS through the instance's private address:

sudo vsftpd-verify.sh
ok    banner '220 FTP server ready (explicit FTPS required)' (no software/version)
ok    FEAT offers AUTH TLS
ok    plain FTP login refused before PASS (530, curl rc 67)
ok    anon login refused over FTPS (530)
ok    ftp login refused over FTPS (530)
ok    root login refused over FTPS (530)
ok    wrong password refused (530)
ok    cleartext data channel refused (522, curl rc 19)
ok    server certificate is this instance's (<fingerprint>...)
ok    tls1_2 accepted (ECDHE AEAD)
ok    tls1_3 refused (TLS 1.2 only)
ok    tls1 refused
ok    tls1_1 refused
ok    non-AEAD (CBC) ciphers refused
ok    upload (EPSV) files/cloudimg-verify-6665.bin
ok    download (PASV) byte-identical
ok    passive data port 40000 inside 40000-40099
ok    PASV reply carries the passive address <public-ip>
ok    data channel protected (PROT P accepted)
ok    listing shows the upload
ok    delete
ok    file gone after delete
ok    file gone from /srv/vsftp/ftpuser/files
ok    5 of 5 consecutive 1 MiB uploads complete and byte-identical
ok    the jail root is not writable (curl rc 25)
ok    CWD .. / ../.. stay at the jail root
ok    ../../../etc/passwd unreachable (curl rc 9)
ok    /etc/passwd (absolute) unreachable (curl rc 9)
ok    /proc/self/root/etc/passwd unreachable
ok    jail root lists only files/
ok    active mode (PORT and EPRT) refused
ok    live session process runs as ftpuser
VSFTPD_ROUNDTRIP_OK target <private-ip>

It takes about 15 seconds, because it tries one wrong password on purpose and every failed login is delayed by three seconds. It leaves the user's directory as it found it.

Step 8: Security checks

Plain FTP is refused. A client that does not start TLS gets the welcome banner (which names no software or version) and is refused at the user name, before it can send a password. The name anonymous is asked for a password (by FTP convention an e-mail address) and then refused, because anonymous logins are off:

IP=$(hostname -I | cut -d" " -f1)
(curl -sv -m 8 -u ftpuser:not-the-password "ftp://$IP/" 2>&1 || true) | grep -E '^[<>] ' | tr -d '\r'
(curl -sv -m 8 "ftp://$IP/" 2>&1 || true) | grep -E '^[<>] ' | tr -d '\r'
< 220 FTP server ready (explicit FTPS required)
> USER ftpuser
< 530 Non-anonymous sessions must use encryption.
< 220 FTP server ready (explicit FTPS required)
> USER anonymous
< 331 Please specify the password.
> PASS ftp@example.com
< 530 Login incorrect.

TLS 1.2 only. Even a client willing to speak the old protocols is refused anything but TLS 1.2, and TLS 1.3 is refused too (Step 15 explains why):

IP=$(hostname -I | cut -d" " -f1)
for v in tls1 tls1_1 tls1_2 tls1_3; do
  r=$(timeout 20 openssl s_client -starttls ftp -$v -cipher 'DEFAULT:@SECLEVEL=0' -connect "$IP:21" </dev/null 2>/dev/null | grep -m1 '^New,')
  case "$r" in *"(NONE)"*|"") echo "$v refused" ;; *) echo "$v accepted: ${r#New, }" ;; esac
done
tls1 refused
tls1_1 refused
tls1_2 accepted: TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384
tls1_3 refused

The configuration. TLS is required for logins and data, anonymous access is off, everyone is jailed in a root owned directory, passive mode uses a fixed range and data connections must come from the client's own address, active mode is off, failed logins are limited, and the protocol stream (which carries PASS) is never logged:

grep -E '^(listen|listen_ipv6|anonymous_enable|local_enable|chroot_local_user|allow_writeable_chroot|local_root|ssl_enable|force_local_logins_ssl|force_local_data_ssl|ssl_ciphers|require_ssl_reuse|pasv_min_port|pasv_max_port|pasv_promiscuous|port_enable|max_login_fails|delay_failed_login|max_per_ip|max_clients|seccomp_sandbox|log_ftp_protocol)=' /etc/vsftpd.conf
grep -E '^(MinProtocol|MaxProtocol|CipherString)' /etc/vsftpd/openssl-vsftpd.cnf
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
chroot_local_user=YES
allow_writeable_chroot=NO
local_root=/srv/vsftp/$USER
ssl_enable=YES
force_local_logins_ssl=YES
force_local_data_ssl=YES
ssl_ciphers=ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:@SECLEVEL=2
require_ssl_reuse=NO
pasv_min_port=40000
pasv_max_port=40099
pasv_promiscuous=NO
port_enable=NO
max_login_fails=3
delay_failed_login=3
max_per_ip=10
max_clients=50
seccomp_sandbox=NO
log_ftp_protocol=NO
MinProtocol = TLSv1.2
MaxProtocol = TLSv1.2
CipherString = ECDHE+AESGCM:ECDHE+CHACHA20:@SECLEVEL=2

/etc/vsftpd/openssl-vsftpd.cnf applies to vsftpd only (vsftpd.service sets OPENSSL_CONF to it); the rest of the system keeps Ubuntu's OpenSSL defaults.

Who may log in. The PAM policy for vsftpd refuses every name in /etc/ftpusers (root, ubuntu and the system accounts) first, then lets in only members of vsftpd-users, and checks their password against /etc/shadow:

grep -vE '^[[:space:]]*(#|$)' /etc/pam.d/vsftpd
grep -vE '^(#|$)' /etc/ftpusers | tr '\n' ' '; echo
auth     required   pam_listfile.so item=user sense=deny file=/etc/ftpusers onerr=fail
auth     required   pam_succeed_if.so user ingroup vsftpd-users quiet_success
auth     required   pam_unix.so
account  required   pam_succeed_if.so user ingroup vsftpd-users quiet_success
account  required   pam_unix.so
session  required   pam_permit.so
root daemon bin sys sync games man lp mail news uucp nobody ubuntu ftp vsftpd-nopriv ssm-user ec2-user 

FTP users can never use SSH. SSH accepts keys only, refuses root, and refuses the whole vsftpd-users group, so even a leaked FTP password opens no shell, port forward or SFTP session:

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|denygroups) '
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
denygroups vsftpd-users

The files that matter are locked down. The credentials file and the TLS key are readable by root only. The jail root /srv/vsftp/ftpuser is owned by root and not writable by the user (vsftpd's safe chroot layout); only files inside it belongs to the user. The FTP user has no shell, and /etc/shadow holds only a yescrypt ($y$) hash of its password:

sudo stat -c '%a %U:%G %n' /root/vsftpd-credentials.txt /etc/vsftpd/tls/vsftpd.key /etc/vsftpd/tls/vsftpd.crt /srv/vsftp /srv/vsftp/ftpuser /srv/vsftp/ftpuser/files
getent passwd ftpuser
id ftpuser
sudo getent shadow ftpuser | cut -d: -f2 | cut -c1-3
600 root:root /root/vsftpd-credentials.txt
600 root:root /etc/vsftpd/tls/vsftpd.key
644 root:root /etc/vsftpd/tls/vsftpd.crt
755 root:root /srv/vsftp
755 root:root /srv/vsftp/ftpuser
750 ftpuser:ftpuser /srv/vsftp/ftpuser/files
ftpuser:x:1001:1001:cloudimg FTPS user:/srv/vsftp/ftpuser:/usr/sbin/nologin
uid=1001(ftpuser) gid=1001(ftpuser) groups=1001(ftpuser),987(vsftpd-users)
$y$

The server is sandboxed and cannot reach the instance metadata service. vsftpd.service can write only to /srv/vsftp, cannot gain privileges, and carries a drop-in that denies the metadata addresses. The check below runs one request from inside vsftpd's own control group (the request fails, 000) and the same request from your shell (it succeeds, 200), so you can see the block is real. The metadata token itself is discarded, never printed:

systemctl show vsftpd -p NoNewPrivileges -p ProtectSystem -p ReadWritePaths -p IPAddressDeny
cat /etc/systemd/system/vsftpd.service.d/50-cloudimg-imds-deny.conf
CG=$(systemctl show -p ControlGroup --value vsftpd)
sudo bash -c "echo \$\$ > /sys/fs/cgroup$CG/cgroup.procs && curl -s -m 5 -o /dev/null -w 'inside vsftpd: %{http_code}\n' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token"
curl -s -m 5 -o /dev/null -w 'your shell:    %{http_code}\n' -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128
ReadWritePaths=/srv/vsftp
ProtectSystem=strict
NoNewPrivileges=yes
# cloudimg: the FTP server never needs the instance metadata service (or the Azure wire server); deny both.
[Service]
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 fd00:ec2::254/128
inside vsftpd: 000
your shell:    200

The preparation services that read the instance's address and host name (vsftpd-prepare, vsftpd-firstboot, vsftpd-set-hostname) run outside the server's control group and use IMDSv2 (a token first); the instance rejects IMDSv1 requests.

No per instance password reached a log, the journal, shell history, backups, /etc or the data volume:

sudo vsftpd-secret-leak-check.sh
LEAKCHECK_OK no per-instance password in the journal, auth.log, syslog, cloud-init, the vsftpd logs, shell history, MOTD, /var/backups, /etc, /srv/vsftp or /tmp

The log shows who did what. vsftpd logs every connection, login and failure with the address the client really connected from (never a forwarded header) in /var/log/vsftpd/vsftpd.log, rotated weekly:

sudo grep -E 'OK LOGIN|FAIL LOGIN' /var/log/vsftpd/vsftpd.log | tail -n 3
Sat Oct 31 20:26:48 2026 [pid 9731] [ftpuser] OK LOGIN: Client "<private-ip>"
Sat Oct 31 20:26:50 2026 [pid 9740] [ftpuser] OK LOGIN: Client "<private-ip>"
Sat Oct 31 20:26:52 2026 [pid 9759] [anonymous] FAIL LOGIN: Client "<private-ip>"

Step 9: Login limits and automatic bans

vsftpd ends a session after 3 failed logins (max_login_fails=3) and waits 3 seconds after every failure (delay_failed_login=3), so each connection can try at most one password every three seconds. One client address may hold at most 10 connections (max_per_ip=10) and the server at most 50 (max_clients=50). Sessions idle for 10 minutes are closed. With a random 24 character password, guessing it this way is not feasible.

This is what three wrong passwords in one encrypted session look like from a client outside AWS: after the third failure the server closes the connection:

220 FTP server ready (explicit FTPS required)
331 Please specify the password.
530 Login incorrect.
331 Please specify the password.
530 Login incorrect.
331 Please specify the password.
530 Login incorrect.
<openssl-process-id>:error:0A000126:SSL routines:ssl3_read_n:unexpected eof while reading:../ssl/record/rec_layer_s3.c:316:

There is no automatic ban. These limits work per session; a client that reconnects can try again. cloudimg does not ship fail2ban, because it is in Ubuntu's community maintained (universe) archive, outside the main archive this image is built from. The best protection is the security group: allow 21 and 40000-40099 only from your users' addresses. If the server must be open to the internet and you want bans, install fail2ban yourself and point its stock vsftpd filter at this image's log file. That filter matches this image's FAIL LOGIN lines (cloudimg checked it with fail2ban-regex against a real log line from this image; fail2ban itself is not installed or tested on the image):

sudo apt-get install -y fail2ban
sudo tee /etc/fail2ban/jail.d/vsftpd-cloudimg.local >/dev/null <<'EOF'
[vsftpd]
enabled  = true
port     = 21,40000:40099
logpath  = /var/log/vsftpd/vsftpd.log
maxretry = 5
findtime = 10m
bantime  = 1h
EOF
sudo systemctl restart fail2ban
sudo fail2ban-client status vsftpd

Step 10: Users and passwords

Every FTP user is a Linux account with no shell, in the group vsftpd-users, jailed to /srv/vsftp/<user> and uploading to /files inside it. Users never see each other's files. Always use the tools below; they create the safe directory layout, pass passwords to the system on standard input only, and refuse names that are already accounts (such as root or ubuntu).

List the users and their upload directories:

sudo vsftpd-listusers
ftpuser                          /srv/vsftp/ftpuser/files

Add a user with a generated 24 character password, shown once:

sudo vsftpd-adduser bob
FTPS user "bob" created: jailed to /srv/vsftp/bob, uploads go to /files (explicit TLS required).
Password (shown once, store it now): <generated-password>

Or set the password yourself on standard input, so it never appears on a command line or in your history (at least 12 characters, no :):

read -rs PW && printf '%s\n' "$PW" | sudo vsftpd-adduser alice --password-stdin; unset PW
FTPS user "alice" created: jailed to /srv/vsftp/alice, uploads go to /files (explicit TLS required).

Change a password (generated, or --password-stdin). The change applies to the next login, without a restart. For ftpuser it also updates FTP_PASSWORD in /root/vsftpd-credentials.txt:

read -rs PW && printf '%s\n' "$PW" | sudo vsftpd-passwd alice --password-stdin; unset PW
sudo vsftpd-passwd ftpuser
Password for "alice" changed.
Updated /root/vsftpd-credentials.txt.
Password for "ftpuser" changed.
New password (shown once, store it now): <generated-password>

Delete a user. This ends the user's open sessions and deletes the account; its files stay in /srv/vsftp/<user>/files unless you add --remove-files (which empties the directory as that user, so a link planted in it is removed, never followed):

sudo vsftpd-deluser alice --remove-files
Removed /srv/vsftp/alice.
FTP user "alice" deleted.

The tools only touch FTP users:

sudo vsftpd-adduser root
sudo vsftpd-deluser ubuntu
vsftpd-adduser: ERROR: 'root' is not a valid new user name (lower-case letters, digits, _ and -, max 32; not an existing account or group; not in /etc/ftpusers)
vsftpd-deluser: ERROR: 'ubuntu' is not an FTP user

The accounts live in the normal system files (/etc/passwd, /etc/shadow, /etc/group), so back those up along with the data volume (Step 13).

Step 11: Passive mode, the passive address and the firewall

FTP uses a second connection for every transfer and listing. This server supports passive mode only: the client opens that connection to a port the server announces from 40000-40099, so your security group must allow that range as well as port 21, from the same client addresses. Active mode is refused (PORT and EPRT get 550 Permission denied.), so the server never opens outbound connections to addresses a client names. A data connection must come from the same address as the client's control connection (pasv_promiscuous=NO).

Clients that use the EPSV command (FileZilla, WinSCP, curl, lftp and most others) reuse the address they connected to and only need the port. Older clients use PASV, where the server also announces an address. A fresh instance only knows its private address, so on every boot vsftpd-prepare (a oneshot service that runs before vsftpd) chooses the address:

  1. If /etc/cloudimg/vsftpd-pasv-address exists, its first line is used: an IPv4 address, a DNS name, or none.
  2. Otherwise the instance's public IPv4 address from instance metadata (IMDSv2) is used. It can change when you stop and start the instance, which is why this runs at every boot.
  3. With neither (a private only instance), no passive address is set and PASV announces the address the client connected to.

The result is written to a runtime file and handed to vsftpd on its command line; /etc/vsftpd.conf itself is never rewritten:

cat /run/cloudimg-vsftpd/pasv.env
ps -o args= -C vsftpd
# Written by vsftpd-prepare at every boot - do not edit. Set your own passive address in /etc/cloudimg/vsftpd-pasv-address
# (an IPv4 address, a DNS name, or "none"), then: sudo vsftpd-prepare --restart
# source: EC2 IMDSv2 public-ipv4
VSFTPD_PASV_ADDRESS=<public-ip>
VSFTPD_PASV_OPTS=-opasv_address=<public-ip>
/usr/sbin/vsftpd /etc/vsftpd.conf -opasv_address=<public-ip>

A PASV session from a computer outside AWS, with curl told to use the announced address (curl -v prints the password line, so the command filters it out), shows the public address and a port inside the range (156*256+118 = 40054), and TLS on both connections:

curl -v --ssl-reqd --cacert vsftpd.crt -u ftpuser --disable-epsv --no-ftp-skip-pasv-ip --list-only ftp://<FTP_HOST>/files/ 2>&1 | grep -v '^> PASS'
< 220 FTP server ready (explicit FTPS required)
> AUTH SSL
< 234 Proceed with negotiation.
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384 / prime256v1 / RSASSA-PSS
< 230 Login successful.
> PBSZ 0
< 200 PBSZ set to 0.
> PROT P
< 200 PROT now Private.
< 257 "/" is the current directory
< 250 Directory successfully changed.
> PASV
< 227 Entering Passive Mode (<public-ip-commas>,156,118).
* Connecting to <public-ip> (<public-ip>) port 40054
< 200 Switching to ASCII mode.
> NLST
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384 / prime256v1 / UNDEF
< 150 Here comes the directory listing.
< 226 Directory send OK.

Elastic IP, DNS name or private only. Behind an Elastic IP, a Network Load Balancer or your own DNS name, write that address or name to /etc/cloudimg/vsftpd-pasv-address; write none when every client is inside the VPC or connects over a VPN or Direct Connect. Then apply it (this rewrites the runtime file and restarts vsftpd, which ends open sessions):

echo 198.51.100.25 | sudo tee /etc/cloudimg/vsftpd-pasv-address
sudo vsftpd-prepare --restart
vsftpd-prepare: passive address: 198.51.100.25 (/etc/cloudimg/vsftpd-pasv-address)

To go back to the instance's public address, delete the file and run sudo vsftpd-prepare --restart again (it then reports (EC2 IMDSv2 public-ipv4)). Do not edit /run/cloudimg-vsftpd/pasv.env itself; it is rewritten at every boot.

If you change pasv_min_port/pasv_max_port in /etc/vsftpd.conf, change the security group to the same range.

Step 12: Your own DNS name and certificate

The rule: cloudimg only ever replaces its own certificate. When cloudimg creates the instance's self signed certificate, it records that certificate's SHA-256 fingerprint in the root owned file /var/lib/cloudimg/vsftpd-tls-cloudimg.sha256. The certificate is re-created only when the installed certificate still matches that fingerprint. A certificate you install at /etc/vsftpd/tls/vsftpd.crt no longer matches, so it is never replaced: not by first boot and not when you change the host name. Check which one is installed:

CUR=$(sudo openssl x509 -noout -fingerprint -sha256 -in /etc/vsftpd/tls/vsftpd.crt | cut -d= -f2)
if [ "$CUR" = "$(sudo cat /var/lib/cloudimg/vsftpd-tls-cloudimg.sha256)" ]; then echo "cloudimg self signed certificate $CUR"; else echo "your own certificate $CUR"; fi
cloudimg self signed certificate <certificate-sha256-fingerprint>

A new name for the self signed certificate. Point a DNS record at the instance (or attach an Elastic IP), then set the name the server presents. While cloudimg's certificate is installed, this creates a new key and self signed certificate for the name and the instance's current addresses, records its fingerprint, updates FTP_HOST and TLS_CERT_SHA256 in /root/vsftpd-credentials.txt, restarts vsftpd and keeps the name (in /etc/cloudimg/vsftpd-hostname) for later runs. Run it without a name after a stop and start has given the instance new addresses:

sudo vsftpd-set-hostname ftp.example.com
Host ftp.example.com; new certificate SHA-256 fingerprint <new-fingerprint> (SAN DNS:ftp.example.com,IP:<public-ip>,IP:<private-ip>,IP:127.0.0.1)

To go back to the EC2 name, delete /etc/cloudimg/vsftpd-hostname and run sudo vsftpd-set-hostname with no name. Your clients will see a new fingerprint each time; compare it with TLS_CERT_SHA256 again.

Your own certificate, for example from Let's Encrypt. FTPS clients cannot use the HTTP challenge, so use a DNS challenge. With the domain in Route 53 and an instance role allowed to change that zone (the Certbot Route 53 plugin runs outside the FTP server, which is the only service blocked from instance metadata):

sudo apt-get install -y certbot python3-certbot-dns-route53
sudo certbot certonly --dns-route53 -d ftp.example.com

vsftpd reads its certificate and key when it starts, and refuses to start (exit status 2) if they do not belong together, so install both files before you restart it. Use the full chain so clients get the intermediate certificate too:

sudo install -o root -g root -m 0600 /etc/letsencrypt/live/ftp.example.com/privkey.pem   /etc/vsftpd/tls/vsftpd.key
sudo install -o root -g root -m 0644 /etc/letsencrypt/live/ftp.example.com/fullchain.pem /etc/vsftpd/tls/vsftpd.crt
sudo systemctl restart vsftpd && sudo vsftpd-healthcheck

Put the first two lines and the restart in a Certbot deploy hook (/etc/letsencrypt/renewal-hooks/deploy/vsftpd) so renewals are installed too, and set the passive address to the same name (Step 11). Your clients then trust the certificate without a fingerprint check. A certificate from your own certificate authority is installed the same way.

From then on, the cloudimg tools leave it alone. On a test instance with a certificate that cloudimg did not create installed:

your own certificate <your-certificate-fingerprint>
vsftpd-set-hostname: ERROR: the installed certificate was not minted by cloudimg (yours?) - not replacing it. Pass --replace-own-cert to replace it with a new self-signed one.

Back to a self signed certificate. To replace your certificate with a new cloudimg self signed one, say so explicitly. This creates a new key and certificate, records the new fingerprint, updates the credentials file and restarts vsftpd:

sudo vsftpd-set-hostname --replace-own-cert
Host ec2-<public-ip-dashed>.compute-1.amazonaws.com; new certificate SHA-256 fingerprint <new-fingerprint> (SAN DNS:ec2-<public-ip-dashed>.compute-1.amazonaws.com,IP:<public-ip>,IP:<private-ip>,IP:127.0.0.1)

Step 13: The data volume

/srv/vsftp is its own EBS volume, mounted by file system UUID with nosuid,nodev,noexec, so uploads can never be run as programs. The image ships it empty; only the jails of the users you create live there:

findmnt -no SOURCE,FSTYPE,OPTIONS /srv/vsftp
df -h --output=size,used,avail,target /srv/vsftp
grep -c ' /srv/vsftp ext4 defaults,nofail,nosuid,nodev,noexec ' /etc/fstab
/dev/nvme1n1 ext4   rw,nosuid,nodev,noexec,relatime
 Size  Used Avail Mounted on
  20G   32K   19G /srv/vsftp
1

To grow it, increase the volume's size in the EC2 console (Volumes > Modify volume), wait until the modification reaches the optimizing state, then grow the file system online (the next boot does it too):

sudo vsftpd-prepare

If the volume is missing (detached, or not attached at launch), vsftpd does not start rather than writing your users' files to the root volume: the service requires /srv/vsftp to be mounted.

Back it up with EBS snapshots or AWS Backup; a snapshot of the data volume holds every user's files.

Step 14: Logs and monitoring

What Where
Connections, logins, failures and transfers (client address from the socket) /var/log/vsftpd/vsftpd.log, rotated weekly, 8 weeks kept
Service start, stop and errors journalctl -u vsftpd
First boot journalctl -u vsftpd-firstboot
Passive address and data volume growth at boot journalctl -u vsftpd-prepare

For CloudWatch, ship /var/log/vsftpd/vsftpd.log with the CloudWatch agent and alarm on a rise in FAIL LOGIN lines. sudo vsftpd-healthcheck exits non-zero when the server is down, so it also works as a check for your monitoring system.

Step 15: Known limitations

cloudimg made these trade-offs deliberately. They are listed so you can judge them for your environment:

  • TLS 1.3 is off for vsftpd. vsftpd 3.0.5 (the newest release) loses uploads over TLS 1.3: on Ubuntu 24.04's package, measured by cloudimg on 31 October 2026, 11 of 20 uploads of 256 KiB over TLS 1.3 failed with 426 Failure reading network stream, while 0 of 40 failed over TLS 1.2 (and 0 of 10 at 8 MiB). A file server that silently drops uploads is worse than one without TLS 1.3, so this image allows TLS 1.2 only, with ECDHE key exchange and AEAD ciphers, which remains a strong configuration. A client configured to require TLS 1.3 cannot connect (curl --tlsv1.3 fails with tlsv1 alert protocol version); use its default settings. First boot and vsftpd-verify.sh upload five 1 MiB files in a row to catch a regression. The rest of the instance (SSH, apt) is not affected.
  • require_ssl_reuse=NO. vsftpd can insist that every data connection resumes the control connection's TLS session, which stops another party that shares the client's address from taking over a transfer. That check is known to refuse data connections from some clients (522 SSL connection failed: session reuse required), so it is off. The data connection must still come from the client's own IP address (pasv_promiscuous=NO) and is always encrypted (PROT P).
  • vsftpd's seccomp sandbox is off (seccomp_sandbox=NO), because its system call filter breaks TLS transfers with current glibc and OpenSSL. Sessions are still confined by the chroot jail, by running as the user's own account, by vsftpd's private process, IPC and network namespaces, and by the systemd sandbox in Step 8.
  • No automatic bans. fail2ban is not shipped (Step 9); rely on the security group, the per instance password and the login limits, or add fail2ban yourself.
  • IPv4 only. vsftpd listens on IPv4 (listen_ipv6=NO).

Step 16: Updates, source and licence

vsftpd is Ubuntu's own package from the main archive, so its security updates and OpenSSL's arrive through apt, and unattended security upgrades are on:

vsftpd -v 0>&1 | head -1
apt-cache policy vsftpd | sed -n 1,3p
grep -vE '^(//|$)' /etc/apt/apt.conf.d/20auto-upgrades
vsftpd: version 3.0.5
vsftpd:
  Installed: 3.0.5-0ubuntu3.1
  Candidate: 3.0.5-0ubuntu3.1
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

cloudimg's configuration (/etc/vsftpd.conf, /etc/pam.d/vsftpd, /etc/ftpusers) replaces the package's defaults, and /etc/systemd/system/vsftpd.service replaces the package's unit. If an update ever changes Ubuntu's default version of one of those configuration files, unattended-upgrades holds that update back instead of asking; install it by hand and keep your configuration:

apt list --upgradable 2>/dev/null | grep vsftpd
sudo apt-get install --only-upgrade -o Dpkg::Options::=--force-confold vsftpd
sudo systemctl restart vsftpd && sudo vsftpd-verify.sh | tail -1

With no update pending, as on a freshly launched instance, the last two commands print:

vsftpd is already the newest version (3.0.5-0ubuntu3.1).
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
VSFTPD_ROUNDTRIP_OK target <private-ip>

Only the three configuration files differ from the package as shipped; the program files are unmodified:

dpkg -V vsftpd; echo "dpkg -V exit code $?"
??5?????? c /etc/ftpusers
??5?????? c /etc/pam.d/vsftpd
??5?????? c /etc/vsftpd.conf
dpkg -V exit code 0

vsftpd is licensed under the GNU General Public License, version 2, with an exception that permits linking with OpenSSL (/usr/share/doc/vsftpd/copyright). The source is Ubuntu's source package (apt-get source vsftpd=3.0.5-0ubuntu3.1, or Launchpad). Provenance notes are in /usr/local/share/doc/vsftpd-cloudimg/README.txt.

To move to a new image version: launch it next to the old instance, stop vsftpd on the old one (sudo systemctl stop vsftpd), snapshot its data volume and create a volume from the snapshot in the new instance's Availability Zone. On the new instance create the same users with vsftpd-adduser, copy each user's files into its new /srv/vsftp/<user>/files directory, and make the user their owner (chown -R <user>:<user>), because the numeric user IDs can differ between instances.

Step 17: Server components

Component Version Path
vsftpd 3.0.5 (Ubuntu package 3.0.5-0ubuntu3.1, main) /usr/sbin/vsftpd
OpenSSL 3.0 (Ubuntu, updated by apt) system library
Ubuntu 24.04 LTS
Path Purpose
/etc/vsftpd.conf Server configuration
/etc/vsftpd/openssl-vsftpd.cnf TLS 1.2 only, for vsftpd only
/etc/pam.d/vsftpd, /etc/ftpusers Who may log in (the vsftpd-users group), and the deny list
/etc/vsftpd/tls/ Certificate and key (directory mode 0700)
/var/lib/cloudimg/vsftpd-tls-cloudimg.sha256 Fingerprint of the certificate cloudimg created (Step 12)
/etc/cloudimg/vsftpd-pasv-address, /etc/cloudimg/vsftpd-hostname Your passive address and host name settings (absent by default)
/run/cloudimg-vsftpd/pasv.env Passive address chosen at boot (Step 11)
/srv/vsftp/<user>/files Each user's upload directory, on the data volume
/var/log/vsftpd/vsftpd.log Connection, login and transfer log
/root/vsftpd-credentials.txt Host, user, password and certificate fingerprint (mode 0600)
/usr/local/sbin/vsftpd-* adduser, passwd, deluser, listusers, set-hostname, prepare, verify.sh, healthcheck, secret-leak-check.sh
vsftpd.service, vsftpd-prepare.service, vsftpd-firstboot.service The server, the per boot preparation and the first boot

To change a setting, edit /etc/vsftpd.conf, then run sudo systemctl restart vsftpd and sudo vsftpd-verify.sh. vsftpd stops at start with exit status 2 on an unknown or invalid setting; journalctl -u vsftpd shows it.

Troubleshooting

  • The client connects and logs in but the listing hangs or times out. The passive data connection is blocked: allow TCP 40000-40099 in the security group (and any network ACL or corporate firewall) from the client's address. Behind NAT, an Elastic IP or a load balancer, set the passive address (Step 11).
  • "530 Non-anonymous sessions must use encryption". The client tried plain FTP. Choose explicit FTP over TLS (Step 5).
  • "522 Data connections must be encrypted". The client encrypts the login but not the data. In lftp set ftp:ssl-protect-data true; in other clients choose Require explicit FTP over TLS.
  • "553 Could not create file". The client is uploading to the top of the jail, which is read only. Upload into /files.
  • "530 Login incorrect". Wrong user or password, or a user that is not in vsftpd-users (create users with vsftpd-adduser). Three failures end the session.
  • "tlsv1 alert protocol version" or a TLS handshake failure. The client requires TLS 1.3 or only offers old ciphers. Allow TLS 1.2 in the client (Step 15).
  • "550 Permission denied" after PORT or EPRT (curl: "Failed to do PORT"). The client is in active mode. Switch it to passive mode.
  • The certificate warning names the wrong host after a stop and start. The public address changed. Run sudo vsftpd-set-hostname (or use an Elastic IP or DNS name, Step 12).
  • vsftpd does not start after you installed a certificate. The key does not match the certificate, or a file is missing. Check journalctl -u vsftpd, install the matching pair (Step 12) and restart.
  • Nothing answers after launch. Check systemctl status vsftpd-firstboot and journalctl -u vsftpd-firstboot, and that the data volume is mounted (findmnt /srv/vsftp). Until first boot completes, the server is deliberately closed.

Security recommendations

  • Allow ports 21 and 40000-40099 only from the addresses your FTP users connect from, and port 22 only from your administrators.
  • Give each person or system its own user; delete users you no longer need.
  • Compare the certificate fingerprint the first time a client connects, or install a certificate your clients trust (Step 12).
  • Keep the operating system patched (unattended security upgrades are on).
  • Back up the data volume and the account files (Step 13).
  • If the server must be reachable from anywhere, add fail2ban (Step 9).

Support and licensing

vsftpd is open source under the GNU General Public License, version 2, with an exception for linking with OpenSSL. There is no per user or per CPU licence fee; the AMI price covers cloudimg's build, hardening, first boot setup and support. vsftpd is the name of the open source Very Secure FTP Daemon by Chris Evans; cloudimg is not affiliated with, endorsed by or sponsored by the vsftpd author.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA