README.md
Rama main · réplica de lectura del repositorio original.
# SFTP + Traefik + Let's Encrypt on Docker Compose
[](https://github.com/heyvaldemar/sftp-traefik-letsencrypt-docker-compose/actions/workflows/deployment-verification.yml)
[](https://www.bestpractices.dev/projects/14843)
[](https://opensource.org/licenses/MIT)
This repository deploys an SFTP server ([atmoz/sftp](https://github.com/atmoz/sftp)) behind Traefik's TCP router, with the Traefik dashboard on HTTPS via Let's Encrypt. Two chrooted accounts are provisioned from `.env`; their data lands on the host under `/srv/sftpusers/<user>`.
## Getting started
```bash
# 1. Clone
git clone https://github.com/heyvaldemar/sftp-traefik-letsencrypt-docker-compose
cd sftp-traefik-letsencrypt-docker-compose
# 2. Create the two Docker networks the stack expects
docker network create traefik-network
docker network create sftp-network
# 3. Create the host data directories
sudo mkdir -p /srv/sftpusers/user1 /srv/sftpusers/user2
# 4. Copy the environment template and fill in required values
cp .env.example .env
$EDITOR .env
# 5. Deploy
docker compose -f sftp-traefik-letsencrypt-docker-compose.yml -p sftp up -d
```
Connect with any SFTP client:
```bash
sftp -P 2222 user1@sftp.example.com
```
### What success looks like
```bash
docker compose -f sftp-traefik-letsencrypt-docker-compose.yml -p sftp ps
sftp -P 2222 user1@YOUR_SERVER # prompts for the password from .env
```
### Common first-deploy issues
- **Connection refused.** The `SFTP_PORT` (default 2222) must be open in your firewall; Traefik publishes it directly.
- **Login works but uploads fail.** Upload into the `data` subdirectory: the chroot home itself is read-only by design (atmoz/sftp requirement).
- **Networks not found.** Step 2 was skipped.
## Supply chain trust
Two images ([`traefik`](https://hub.docker.com/_/traefik) and [`atmoz/sftp`](https://hub.docker.com/r/atmoz/sftp)) pinned by digest as interpolation defaults in the compose `x-images` block (atmoz/sftp publishes no semver tags, so the `debian` tag is pinned to an exact digest). `git pull` alone delivers the tested combination.
Two override levels exist per image. `<PREFIX>_IMAGE_VERSION` in `.env` swaps only the version of that image (Compose then pulls the tag, without a digest) and leaves every other pin as tested; `<PREFIX>_IMAGE_TAG` replaces the whole reference, digest included. The variable names are listed in `.env.example`. Nested defaults need Docker Compose v2.5 or newer (2022); v2.0 to v2.4 leave the inner `${...}` unexpanded and `docker compose up` fails with an invalid reference instead of deploying something unexpected.
The daily `check-pin-freshness` CI job re-resolves each pin against its registry; GitHub Actions are pinned by commit SHA and Dependabot keeps those fresh.
### Verify what you deploy
Every release from v1.4.4 on carries three files made on GitHub's runner with a short-lived identity and no stored key: `sftp-traefik-letsencrypt-docker-compose-<tag>.tar.gz`, a `git archive` of exactly the tree the tag points at; `sftp-traefik-letsencrypt-docker-compose-<tag>.tar.gz.sigstore.json`, a keyless [Sigstore](https://www.sigstore.dev/) signature over it; and `sftp-traefik-letsencrypt-docker-compose-<tag>.intoto.jsonl`, [SLSA](https://slsa.dev/) build provenance from the SLSA generator. To check them with nothing from this repository trusted:
```bash
cosign verify-blob sftp-traefik-letsencrypt-docker-compose-<tag>.tar.gz \
--bundle sftp-traefik-letsencrypt-docker-compose-<tag>.tar.gz.sigstore.json \
--certificate-identity-regexp '^https://github.com/heyvaldemar/sftp-traefik-letsencrypt-docker-compose/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
slsa-verifier verify-artifact sftp-traefik-letsencrypt-docker-compose-<tag>.tar.gz \
--provenance-path sftp-traefik-letsencrypt-docker-compose-<tag>.intoto.jsonl \
--source-uri github.com/heyvaldemar/sftp-traefik-letsencrypt-docker-compose
```
Add `--source-tag <tag>` for a release published after 24 September 2026, which is signed by the run that published it. The five releases before that date were signed by a run started by hand on `main`, so their provenance names the branch, not the tag; the archive is still the tag's tree, and the signature still belongs to this repository's workflow. The workflow that makes them is [`release-assets.yml`](.github/workflows/release-assets.yml).
## Production checklist
- [ ] **Strong passwords**: 24+ random characters per account; regenerate the Traefik dashboard hash.
- [ ] **Prefer SSH keys** for real workloads: atmoz/sftp supports mounting public keys per user; password auth is the lowest bar.
- [ ] **Back up `/srv/sftpusers/`**: that's where all uploaded data lives.
- [ ] **Watch the upstream image**: atmoz/sftp moves slowly; the weekly digest check tells you when a rebuild lands.
## Unattended updates
Releases are the update channel: a tag is cut only after CI has built the pinned images, booted the full stack, and passed the smoke tests. `update.sh` moves a deployment to the newest tag and nothing else:
```bash
./update.sh --dry-run # show what would be applied
./update.sh # update within the current major and redeploy
```
Put it on a timer for hands-off minor/patch updates:
```bash
# crontab -e
17 5 * * * /opt/sftp-traefik-letsencrypt-docker-compose/update.sh >> /var/log/sftp-update.log 2>&1
```
The script refuses to cross a MAJOR template version on its own: majors are breaking by definition and their release notes exist to be read. After reading them, `./update.sh --allow-major` performs the jump. It also refuses to touch a checkout with local modifications: your customization belongs in `.env`, which updates never overwrite.
This is deliberately a host-side script and not a container in the stack: an in-stack updater needs the Docker socket (root on the host) and turns "someone pushed to a repo" into "someone deployed to your machine" with no operator in the loop. A cron job under your own user updates only to tagged, CI-verified states and leaves the trust boundary where it was.
## Resource limits
Every service carries memory and CPU limits plus reservations as compose-level defaults, the same values CI boots the stack under. Override any of them in `.env` (the knobs and their defaults are listed in `.env.example`, e.g. `TRAEFIK_MEMORY_LIMIT=512m`) and the override survives every `git pull`. If a service is OOM-killed under real load, `docker inspect <container> --format '{{.State.OOMKilled}}'` says so; raise its `_MEMORY_LIMIT` and recreate.
## Backups
The SFTP service keeps no state of its own: users and keys come from `.env` and the mounted host directories are the data. Back up those host directories with whatever already covers the host (restic, rclone, Borg, S3 sync); there is no database and no volume beyond Traefik's certificates, which are re-issued automatically.
## Container hardening
Every service runs with `security_opt: no-new-privileges:true`, so a process cannot gain privileges through setuid binaries even if it escapes its initial capability set. Infrastructure containers (the reverse proxy, databases, caches, backups) run with `cap_drop: [ALL]` and add back only what their entrypoints need: `NET_BIND_SERVICE` for Traefik to bind :80/:443, `CHOWN`/`SETUID`/`SETGID` (and friends) for database images to own their data directory and drop to their service user. Application containers keep the default capability set on purpose: upstream images assume it, and a wrong guess there is a boot loop in production rather than a hardening win. CI boots the stack under exactly these settings on every push, so what ships is what was tested.
## Testing
The [Deployment Verification](https://github.com/heyvaldemar/sftp-traefik-letsencrypt-docker-compose/actions/workflows/deployment-verification.yml?query=branch%3Amain) workflow runs on every push, pull request, and every day at 06:00 UTC: actionlint, Trivy scans of both pinned images, the weekly digest check, and a deploy-and-test job that performs a real SFTP login and directory listing through Traefik's TCP router with an ephemeral password.
## Security notes
- Credentials are read from `.env` at deploy time; `.env` is gitignored and compose fails fast on missing required variables.
- **Pre-rotation advisory.** Releases before v1.0.0 (2026-09-01) shipped a tracked `.env` with generated-looking account passwords. Rotate them if your deployment reused them.
- Each account is chrooted to its own home; users cannot see each other's data.
---
## About the maintainer
<div align="center">
**Maintained by [Vladimir Mikhalev](https://github.com/heyvaldemar)** · Docker Captain · CNCF Ambassador · IBM Champion · AWS Community Builder
[YouTube](https://www.youtube.com/channel/UCf85kQ0u1sYTTTyKVpxrlyQ?sub_confirmation=1) · [Blog](https://heyvaldemar.com) · [LinkedIn](https://www.linkedin.com/in/heyvaldemar/)
</div>