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
MIT licensed OpenSSH signed images (Cosign) SBOM available
The idea

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.

Features

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.

How it works

Declare it, and it converges

How zocalo-sftp works Two read-only config files, sftp_users.conf and sftp_projects.conf, are watched by sftp-reconciled, a small Rust binary that runs an init pass then a watch loop. It converges the system's real users and groups in /etc/passwd, /etc/shadow and /etc/group, and the chrooted project directories under /projects/. sshd authenticates each user against that identity and serves them chrooted in /projects/, so every user sees only the projects their group owns. Touching .generation forces an immediate reconcile; otherwise it re-applies on a 15-second tick. config · mounted read-only sftp_users.conf username : uid sftp_projects.conf name : gid : users sftp-reconciled init + watch loop · Rust inotify .generation · 15s tick /etc/passwd /shadow · /group real users & groups /projects/ ├── station-ops/ sheridan, garibaldi └── medical/ franklin only setgid dirs · parent owned by root sshd chroot jail · SFTP-only no shell · no root login users + groups project dirs auth chroot Each user lands chrooted in /projects/ and sees only the projects their group owns.

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.

Quick start

Up in five minutes

Two config files, one compose file, four ed25519 keys. Reproduced verbatim from the README.

config/sftp_users.conf — format: username:uid
config/sftp_users.conf
# sftp_users.conf - username:uid
sheridan:1001
garibaldi:1002
franklin:1003
config/sftp_projects.conf — format: project_name:gid:user1,user2,…
config/sftp_projects.conf
# sftp_projects.conf - project_name:gid:user1,user2,...
station-ops:2001:sheridan,garibaldi
medical:2002:franklin
docker-compose.yml
docker-compose.yml
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
Generate keys
shell
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 ""
Connect
shell
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/.

Authentication

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).

SFTP_AUTH_MODE — copied from the README
ModeAcceptsRequires secret(s)
pubkey (default)ed25519 public keyusername.authorized_keys
certCA-signed certificatessh_user_ca.pub
pubkey|certPublic key or certificateusername.authorized_keys + ssh_user_ca.pub
passwordPasswordusername.password
pubkey|passwordPublic key or passwordusername.authorized_keys + username.password
cert|passwordCertificate or passwordssh_user_ca.pub + username.password
anyAny of the aboveAll or some of the above
pubkey,passwordPublic key and password (2FA)username.authorized_keys + username.password
cert,passwordCertificate and password (2FA)ssh_user_ca.pub + username.password

Authentication section in the README → secret paths, rotation behaviour, per-group overrides.

Security

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 allowlist
  • no-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
  • Dockerfile builds 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.

Honest fit check

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.

Examples

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.d files 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.

FAQ

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:

shell
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.

About

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.

AI-assisted development, disclosed plainly: the implementation (shell scripts, Rust reconciler, Kubernetes manifests, tests, documentation) was written with heavy use of coding agents — Claude Code and Oh-My-Pi, running on the maintainer's own self-hosted AI infrastructure. The architecture and every consequential design decision are the maintainer's, not the model's. Details in ARCHITECTURE.md § Design Decisions.