Hardened SFTP server for containers
Native Linux groups and file permissions instead of virtual users or a database. Users and projects live in two config files; a reconciliation loop keeps the container converged on what you declared.
docker pull wiesion/zocalo-sftp:latest
Three things, nothing more
Native groups & permissions
Access control is plain Linux group membership and directory permissions. No database, no virtual-user layer, no authorization engine to audit.
Declarative, reconciled
Users and projects are declared in two mounted config files. A reconciliation loop converges /etc/passwd, /etc/shadow and /etc/group toward that state.
Small enough to audit
Roughly 400 lines of shell and config, roughly 1200 lines of Rust. No framework, no runtime magic — one repo you can read end to end.
What you get
SFTP-only, chrooted
Users land in /projects/ and see only the projects their group is a member of. No shell, no root.
Project isolation by permissions
setgid group directories, parent owned by root — users can't delete whole projects.
Auth modes via one variable
pubkey (default, ed25519), CA certificates, password, and combinations including 2FA (pubkey,password).
Live reconciliation
Every 15s by default, or immediately on a .generation bump. Key and password rotation without restart.
Modern crypto only
ed25519 keys, curve25519 / sntrup761x25519 KEX, AEAD ciphers. Root login, empty passwords and shell access are always disabled.
Prometheus metrics
Opt-in on port 9100: active connections, active users, disk usage, per-project usage.
Plain-text logs
One line per event to stdout, ready for any log shipper. A complete Vector example is included.
Container hardening
cap_drop: ALL with a minimal allowlist, no-new-privileges, Wolfi base image, no shadow-utils.
Signed images
Cosign keyless signatures on every release, plus an SBOM generation script (syft, SPDX + CycloneDX).
Kubernetes manifests
StatefulSet, split liveness/readiness probes, NetworkPolicy — tested on kind + Calico.
Any SFTP client
FileZilla, WinSCP, Cyberduck, or the command line: sftp, pscp -sftp.
Migration path
Moving from atmoz/sftp or emberstack/sftp-server? The config format needs adaptation, but the model is familiar.
Declare it, and it converges
Declare
Write sftp_users.conf and sftp_projects.conf. That's the whole configuration.
Mount
Mount ./config read-only and your secrets as Docker/Kubernetes secrets.
Reconcile
sftp-reconciled validates, renders sshd_config, then converges users, groups and project dirs — on start and on every change.
Connect
Any SFTP client, any user from your config, lands chrooted in /projects/ with exactly their share.
Read ARCHITECTURE.md → startup sequence, reconciliation internals, sshd_config include ordering, logging pipeline.
Up in five minutes
Two config files, one compose file, four ed25519 keys. Reproduced verbatim from the README.
# sftp_users.conf - username:uid
sheridan:1001
garibaldi:1002
franklin:1003
# sftp_projects.conf - project_name:gid:user1,user2,...
station-ops:2001:sheridan,garibaldi
medical:2002:franklin
services:
sftp:
image: wiesion/zocalo-sftp:latest
ports:
- "2222:22"
secrets:
- ssh_host_ed25519_key
- sheridan.authorized_keys
- garibaldi.authorized_keys
- franklin.authorized_keys
volumes:
- ./data:/sftp-jail/projects
- ./config:/config:ro
# Runtime hardening, explained under Container Hardening below.
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, FSETID, NET_BIND_SERVICE, SETGID, SETUID, SYS_CHROOT]
security_opt: ["no-new-privileges:true"]
secrets:
ssh_host_ed25519_key:
file: ./secrets/ssh_host_ed25519_key
sheridan.authorized_keys:
file: ./secrets/sheridan_key.pub
garibaldi.authorized_keys:
file: ./secrets/garibaldi_key.pub
franklin.authorized_keys:
file: ./secrets/franklin_key.pub
ssh-keygen -t ed25519 -f secrets/ssh_host_ed25519_key -N ""
ssh-keygen -t ed25519 -f secrets/sheridan_key -N ""
ssh-keygen -t ed25519 -f secrets/garibaldi_key -N ""
ssh-keygen -t ed25519 -f secrets/franklin_key -N ""
sftp -P 2222 -i secrets/sheridan_key sheridan@localhost
sftp> cd station-ops
sftp> put report.txt
Every snippet above is verbatim from the README's Quick Start. Working Docker Compose setups for each scenario are in examples/.
One variable, nine modes
Authentication is controlled entirely by SFTP_AUTH_MODE. Default: pubkey (ed25519 public key only). | = OR — any listed mechanism is sufficient. , = AND — all listed mechanisms are required (2FA).
| Mode | Accepts | Requires secret(s) |
|---|---|---|
pubkey (default) | ed25519 public key | username.authorized_keys |
cert | CA-signed certificate | ssh_user_ca.pub |
pubkey|cert | Public key or certificate | username.authorized_keys + ssh_user_ca.pub |
password | Password | username.password |
pubkey|password | Public key or password | username.authorized_keys + username.password |
cert|password | Certificate or password | ssh_user_ca.pub + username.password |
any | Any of the above | All or some of the above |
pubkey,password | Public key and password (2FA) | username.authorized_keys + username.password |
cert,password | Certificate and password (2FA) | ssh_user_ca.pub + username.password |
Authentication section in the README → secret paths, rotation behaviour, per-group overrides.
Small surface, audited by you
This project is not a complete security solution — it has no built-in IP banning, rate limiting, or intrusion detection. What it does: a minimal attack surface and the primitives to audit every layer yourself.
Cryptography
- Host keys: ed25519 only
- KEX: sntrup761x25519-sha512, curve25519-sha256
- Ciphers: AES-GCM, ChaCha20-Poly1305 (AEAD only)
- Public keys: ed25519, including certificate signatures
- Always disabled: root login, empty passwords, shell access
Container hardening
- Wolfi base image, no shadow-utils
cap_drop: ALL+ minimal allowlistno-new-privileges/allowPrivilegeEscalation: false- Users chrooted; SFTP subsystem only
- Startup refuses colliding UIDs/GIDs and jail-weakening drop-ins
Supply chain
- Cosign keyless signatures on every release
- SBOM script: SPDX + CycloneDX via syft
Dockerfilebuilds standalone, no CI required
Report vulnerabilities through SECURITY.md (GitHub Private Vulnerability Reporting, not a public issue). The README's Security section covers the full capability list and the reasoning behind it.
Is this the right tool?
Use this if you need…
- SFTP access for developers, clients, or automated systems
- File sharing or collaboration via standard SFTP clients
- Deployment targets for applications that push files via SFTP
- A legacy FTP server replacement
- Something you can understand completely by reading ~400 lines of shell/config and ~1200 lines of Rust
Look elsewhere if you need…
- A web UI for user management
- Virtual / database-backed users — sftpgo is the better choice
- S3 or cloud storage backends — again, sftpgo's territory
- Advanced quota management or a REST API for automation
- Built-in IP banning or intrusion detection — that's your SIEM or a fail2ban sidecar
Full text in the README's "What This Is (and Isn't)" section.
Ten working setups
Each example ships with setup.sh (interactive) and test.sh (automated: auth, visibility, file operations, permissions).
-
01-basic-dev
Local development with volume-mounted secrets.
-
02-docker-secrets
Production deployment with Docker secrets.
-
03-cloud-native
Prometheus metrics, log shipping to Vector, custom SSH config.
-
04-multi-project
Complex access patterns with multiple teams.
-
05-certificate-auth
CA certificate authentication with
SFTP_AUTH_MODE: cert. -
06-password-auth
Password authentication with
SFTP_AUTH_MODE: password. -
07-2fa
Two-factor authentication with
SFTP_AUTH_MODE: pubkey,password. -
08-custom-config
Drop-in
sshd_config.dfiles for per-group and per-user overrides. -
09-kubernetes
StatefulSet, split liveness/readiness probes, tested NetworkPolicy (kind + Calico).
-
10-security-boundary
Adversarial tests: hostile config, a hostile non-member client, drift repair, revocation on removal.
Common questions
Why not virtual users?
Because the access-control model is then just the Linux permission model you already understand: groups, ownership, setgid. No database, no bespoke authorization layer, no second mental model. The tradeoff: user management is declarative config, not a dynamic API. If you need a web UI or database-backed users, sftpgo is the right tool.
How do I add a user?
Three steps: add a line to sftp_users.conf (and a project line if they need one), provide the username.authorized_keys secret, then bump the generation counter to trigger an immediate reconcile:
printf '%s\n' "$(($(cat config/.generation) + 1))" > config/.generation
Without the bump, the reconcile loop picks it up within SFTP_RECONCILE_INTERVAL seconds (default 15). See Runtime Reconciliation.
Does it support PROXY protocol, or show real client IPs behind a load balancer?
No. sshd has no native PROXY protocol support — pointing a proxy that sends those headers directly at this container breaks the connection outright, not just the IP. Behind a SNAT-performing load balancer, sshd logs the balancer's IP. For Kubernetes, set externalTrafficPolicy: Local on your Service to avoid kube-proxy SNAT (at the cost of uneven load across nodes). See Client IP Behind a Proxy or Load Balancer.
What capabilities does the container need, and why?
cap_drop: ALL, then exactly seven back: CHOWN (file ownership during setup), DAC_OVERRIDE (writing /etc/shadow, which ships mode 0000 on Wolfi), MKNOD (device nodes in the chroot), NET_BIND_SERVICE (port 22 and 9100), SETGID and SETUID (user/group operations, sshd privilege separation), and SYS_CHROOT (the SFTP jail). Full list with the Kubernetes equivalent in the README's Container Hardening section.
Does it log file operations?
Only at SFTP_LOG_LEVEL: INFO or more verbose. The default, ERROR, logs failures only — a normal upload or delete produces no log line. Upload, download, delete, rename and mkdir appear in the internal-sftp stream at INFO. See Logging.
Where do I report a vulnerability?
GitHub's Private Vulnerability Reporting, not a public issue — Security tab → Report a vulnerability. Only the latest tagged release receives security fixes; re-pull :latest rather than expecting patches on older tags.
Is it production-ready?
The original image ran in production for years in a Docker Swarm deployment before being published. This public version added the test coverage, Kubernetes manifests and documentation that publishing required. The maintainer's honest framing: this is a small, composable piece of infrastructure that trusts you to configure it correctly — the README's Operator Responsibilities section spells out exactly what is and isn't validated.
Origin & disclosure
zocalo-sftp started as a manually written Docker image that ran in production for years as part of a Docker Swarm deployment. To publish it as an open-source project, it needed much more test and documentation coverage, plus Kubernetes primitives — which is what the current version is.