Dockerized vsftpd on Raspberry Pi (arm64)

A complete, reusable walkthrough for deploying vsftpd in Docker with:

  • Multiple storage mount points
  • Read-only and read-write users
  • Proper Linux permission enforcement
  • Passive mode fixes behind Docker NAT
  • UID/GID alignment between host and container
  • ACL cleanup and troubleshooting

This guide is intentionally generic so it can be reused on any host.


Environment

  • OS: Debian / Raspberry Pi OS
  • Architecture: arm64 / aarch64
  • Docker + Docker Compose
  • Image: forumi0721/alpine-vsftpd:aarch64

1. Prepare Host Storage

Example generic layout (mounted at fixed paths via fstab):

/srv/ftp/storage1/public
/srv/ftp/storage1/private
/srv/ftp/storage2/media
/srv/ftp/storage2/restricted

Mount USB drives at fixed paths (fstab)

Identify the drive UUID and filesystem type:

lsblk -f
blkid

Create the mount points and FTP subfolders:

sudo mkdir -p /srv/ftp/storage1/{public,private} /srv/ftp/storage2/{media,restricted}

Add entries to /etc/fstab using UUIDs so mount points never change (replace placeholders):

# ext4 (recommended, full POSIX perms)
UUID=<UUID_STORAGE1>  /srv/ftp/storage1  ext4  defaults,noatime,nofail,x-systemd.device-timeout=10s  0  2

# exFAT or NTFS3 (permissions are mapped via mount options)
UUID=<UUID_STORAGE2>  /srv/ftp/storage2  exfat  defaults,uid=1000,gid=1000,umask=022,nofail,x-systemd.device-timeout=10s  0  0
# For NTFS, use: ntfs3

Example fstab (ext4 on both drives)

Example from one host with two ext4 USB disks. Replace UUIDs and mount points with your own:

# ADATA1TB -> /srv/ftp/storage1
UUID=3afa249d-130f-4b59-99b2-a353d0f69311  /srv/ftp/storage1  ext4  defaults,noatime,nofail,x-systemd.device-timeout=10s  0  2

# WD1TB -> /srv/ftp/storage2
UUID=10fb607b-1e19-4b9b-a5c4-11046f016366  /srv/ftp/storage2  ext4  defaults,noatime,nofail,x-systemd.device-timeout=10s  0  2

Checklist for each fstab line:

  • UUID is from lsblk -f or blkid
  • Mount point exists and matches your docker-compose volume paths
  • Filesystem type matches the disk
  • Options include nofail and a short x-systemd.device-timeout

Notes:

  • Use the filesystem type reported by lsblk -f (ext4, exfat, ntfs3).
  • exFAT/NTFS do not honor per-file ownership and permissions; access is controlled by mount options.
  • For on-demand mounts while keeping fixed paths, add x-systemd.automount,x-systemd.idle-timeout=30 to the options list.

Mount and verify:

sudo mount -a
findmnt -no SOURCE,FSTYPE,OPTIONS /srv/ftp/storage1 /srv/ftp/storage2

Raspberry Pi OS Desktop automount

Disable the file manager automount so drives are not mounted under /media/$USER and /srv/ftp/... stays populated:

  1. Open File Manager.
  2. Go to Edit -> Preferences -> Volume Management.
  3. Uncheck “Mount removable media automatically” and “Show available options for removable media on insertion”.

If you want to disable desktop automounts entirely (affects all removable media), stop udisks2:

sudo systemctl disable --now udisks2.service

If a USB drive is underpowered or slow to spin up, it may fail to mount during boot. Use a powered USB hub or add a longer x-systemd.device-timeout value if you see intermittent mount failures.

Filesystem choice (ext4 vs exFAT/NTFS3)

| FS | Pros | Cons | Best for | |—-|——|——|———-| | ext4 | True POSIX perms, ACLs, symlinks | Not native on Windows | Mixed RO/RW folders and strict permissions | | exFAT | Cross-platform | Permissions are mapped via mount options | Simple shared storage with uniform access | | NTFS (ntfs3) | Cross-platform, journaling | Permissions are mapped via mount options | Windows compatibility with large files |

If you need per-directory RO/RW on the same disk, use ext4. With exFAT/NTFS, keep RO and RW data on separate disks or accept a single permission model for the whole volume.

Permission goals

  • public, media: readable by all users
  • private, restricted: admin-only
sudo chmod 755 /srv/ftp/storage1/public /srv/ftp/storage2/media
sudo chmod 750 /srv/ftp/storage1/private /srv/ftp/storage2/restricted

If you used exFAT/NTFS, chmod/chown will not apply; use mount options and avoid mixing RO/RW folders on the same volume.

Docker and vsftpd do not bypass Linux permissions.


2. Create Project Directory

mkdir -p ~/vsftpd
cd ~/vsftpd

3. docker-compose.yml

services:
  vsftpd:
    build: .
    container_name: vsftpd
    restart: unless-stopped
    ports:
      - "21:21/tcp"
      - "60000-60099:60000-60099/tcp"
    environment:
      FTP_USER_RW: ftpadmin
      FTP_PASS_RW: adminpassword
      FTP_USER_RO: ftpuser
      FTP_PASS_RO: readonlypassword
    volumes:
      - /srv/ftp/storage1/public:/data/public:ro
      - /srv/ftp/storage1/private:/data/private:rw
      - /srv/ftp/storage2/media:/data/media:ro
      - /srv/ftp/storage2/restricted:/data/restricted:rw

4. vsftpd.conf

listen=YES
listen_ipv6=NO

anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=022

chroot_local_user=YES
allow_writeable_chroot=YES
local_root=/data

pasv_enable=YES
pasv_min_port=60000
pasv_max_port=60099
pasv_address=<HOST_IP>
pasv_addr_resolve=NO

5. entrypoint.sh

#!/bin/sh
set -e

# Create users with fixed IDs for permission stability
adduser -D -u 1000 ftpadmin || true
adduser -D -u 1001 ftpuser || true

echo "ftpadmin:${FTP_PASS_RW}" | chpasswd
echo "ftpuser:${FTP_PASS_RO}" | chpasswd

# Extra guard: restrict sensitive directories
chmod 750 /data/private /data/restricted 2>/dev/null || true

exec /usr/sbin/vsftpd /etc/vsftpd/vsftpd.conf

6. Dockerfile

FROM forumi0721/alpine-vsftpd:aarch64

COPY vsftpd.conf /etc/vsftpd/vsftpd.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

7. Build and Run

docker compose up -d --build

8. Passive Mode NAT Fix (Critical)

If clients hang on directory listing and logs show:

227 Entering Passive Mode (172.18.x.x)

Set:

pasv_address=<HOST_IP>

Rebuild the container after changes.


9. UID/GID Alignment (Most Common Failure)

Check container user IDs:

docker exec -it vsftpd id ftpadmin

Example:

uid=1000(ftpadmin) gid=1000(ftpadmin)

Align ownership on host:

chown -R 1000:1000   /srv/ftp/storage1/public   /srv/ftp/storage1/private   /srv/ftp/storage2/media   /srv/ftp/storage2/restricted

Apply permissions:

chmod 755 /srv/ftp/storage1/public /srv/ftp/storage2/media
chmod 750 /srv/ftp/storage1/private /srv/ftp/storage2/restricted

10. ACL Cleanup (Hidden Issue)

If permissions show a +:

ls -ld private

Install ACL tools:

apt install acl

Remove ACLs recursively:

setfacl -Rb private
setfacl -Rb restricted

Verify ACLs are gone:

getfacl -p private

11. Final Verification

docker exec -it vsftpd sh -lc 'su -s /bin/sh -c "cd /data/private && echo ftpadmin OK" ftpadmin &&  su -s /bin/sh -c "cd /data/private" ftpuser || echo "ftpuser blocked"'

Expected:

ftpadmin OK
ftpuser blocked

Final Access Matrix

User public media private restricted
ftpadmin RW RW RW RW
ftpuser RO RO

Lessons Learned

  • Docker does not override Linux permissions
  • UID/GID alignment is mandatory for bind mounts
  • ACLs silently override chmod
  • vsftpd passive mode must advertise the host IP
  • Mount storage at fixed paths via fstab; avoid desktop automounters

This guide is intentionally generic and reusable for future deployments.