The summary printed at the end of every run says passive FTPS behind NAT is
fixed by re-running with FTP_NAT=... That could never work: the ftp-nat
substitution sat inside `if [[ ! -f "$CONF" ]]`, so on any re-run the conf
existed, the substitution was skipped, and ftp-nat stayed commented out.
cfg/copyparty.conf is the ONLY route FTP_NAT has to the service -- nothing
in docker-compose.yml interpolates it and the copyparty service is given no
environment, so .env's copy is a record, not the live setting. The remedial
run logged "exists; leaving it alone" as a green [+], reported healthy, and
reprinted the same advice. Passive FTPS failed exactly as before, with
nothing to distinguish "you did it wrong" from "it didn't take" -- and
because the template's commented example carries a literal IP, grepping the
conf shows an ftp-nat line with an address in it.
Lift the application out of the creation guard so it runs against an
existing conf too, and match `#?` so an already-set value (ISP change, typo)
is corrected rather than only the commented template line. Warn instead of
guessing if no ftp-nat line exists at all -- appending at EOF would land in
[accounts]. Kept above the chown, since `sed -i` rewrites as root.
`|| true` on the current-value read is load-bearing: on a still-commented
conf the grep matches nothing, and under `set -o pipefail` that would abort
the script inside the assignment -- the exact trap fixed in bf52426.
Also restart copyparty when the conf changed: compose will not recreate a
service whose image and config are unchanged, so `up -d` alone would leave
the edit on disk and the old value in the running process. And record the
value in .env so the two files do not disagree about what is deployed.
Verified: commented template applies; identical value is a no-op; a
different value is corrected; a conf with no ftp-nat line warns without
corruption; empty FTP_NAT skips the block entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
copyparty
copyparty — a portable file server with
resumable uploads, dedup, WebDAV, SFTP, FTPS, media indexing and
thumbnails — behind Caddy with automatic Let's Encrypt TLS. Ships with a
security-notices-aware updater (update.sh).
The web UI / WebDAV is fronted by Caddy on 443; SFTP and FTPS are served directly by copyparty (Caddy is HTTP-only and not in that path).
Required .env values
| Variable | Notes |
|---|---|
COPYPARTY_DOMAIN |
Public hostname for the web UI (e.g. files.example.com). |
ACME_EMAIL |
Let's Encrypt registration email. |
DATA_DIR |
Host folder shared as the data root (mounted at /w). Point at a data disk. |
COPYPARTY_IMAGE / COPYPARTY_TAG |
Image edition (copyparty/ac) + pinned version. |
SFTP_PORT / FTPS_PORT / FTP_PASV_RANGE |
Direct-listener ports (3922 / 3990 / 12000-12099). |
FTP_NAT |
This host's public IP, for passive FTPS through NAT (blank if direct). |
UPDATE_POLICY |
latest (default) / security / off — see Updates. |
See .env.example for the rest. Accounts and shares live in
copyparty.conf.example (deploy.sh generates the real
cfg/copyparty.conf with a random admin password on first run).
Deploy
./automations.sh # Deploy on this host → deploy: copyparty
Or build + run the self-contained artifact:
./build.sh
scp deploy.sh root@host:
ssh root@host 'bash deploy.sh'
# non-interactive:
# COPYPARTY_DOMAIN=files.example.com ACME_EMAIL=me@example.com SKIP_PROMPTS=1 bash deploy.sh
Unattended provisioning: cloud-init.yml. On first run deploy.sh
pins COPYPARTY_TAG to the newest release, generates cfg/copyparty.conf (random
admin password, printed once) and a self-signed cfg/ftps.pem, opens the ports,
brings the stack up, and schedules update.sh.
Accounts & shares
The default config is private: one admin account with full access to the
whole data root, nothing public. Edit cfg/copyparty.conf (commented examples
included) to add users or open read-only / anonymous-upload shares, then hot-reload:
docker compose exec copyparty kill -s USR1 1
Real client IP (behind Caddy)
For the web UI, copyparty must see the genuine client IP (for its logs and abuse bans), which means both sides are configured:
- Caddy is the internet edge, so it appends the real address to
X-Forwarded-Forand also setsX-Real-IP(seeCaddyfile). - copyparty trusts that upstream via
xff-src: lanincopyparty.conf.example— without it copyparty ignores the header and logs/bans the Docker-network address.
lan (trust private ranges) is safe here because copyparty's HTTP port is never
published — only Caddy can reach it. If copyparty prints an xff-src warning at
startup, narrow it to the exact subnet it names. SFTP/FTPS are direct TCP, so
they already see the real client IP (no XFF involved).
SFTP & FTPS
Both authenticate against the same [accounts] as the web UI.
sftp -P 3922 admin@files.example.com # SFTP (password auth enabled)
lftp -u admin -e 'set ftp:ssl-force true; set ssl:verify-certificate no' \
ftp://files.example.com:3990 # explicit-TLS FTPS
-
SFTP host key is generated on first start and persisted under
/cfg, so clients don't get "host key changed" warnings across restarts. -
FTPS cert defaults to the self-signed
cfg/ftps.pem(clients must accept it). To serve a publicly-trusted cert, reuse Caddy's Let's Encrypt cert: mount thecaddy-datavolume read-only into the copyparty service and point copyparty at it, e.g.# docker-compose.yml, copyparty service: volumes: - caddy-data:/caddy:ro# cfg/copyparty.conf [global]: cert: /caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/files.example.com/files.example.com.crt certkey: /caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/files.example.com/files.example.com.key(copyparty re-reads the cert on
kill -s USR1; refresh after a renewal.) -
Passive FTPS needs the
FTP_PASV_RANGEports published (they are) andFTP_NATset to the server's public IP when clients connect through NAT. -
A published Docker port bypasses the host
INPUTfirewall, so setBIND_ADDRto pin the listeners to a trusted interface on multi-homed hosts.
Updates & security notices
copyparty doesn't self-update, but it publishes a machine-readable
security-advisories feed (the same one its built-in --vc-url check uses).
This deployment uses it on two levels:
- In-app —
vc-url+vc-ageincfg/copyparty.confmake copyparty itself check whether the running version has a known advisory and warn in the log / control panel. (vc-exit, opt-in, makes it shut down if vulnerable.) update.sh— reads the same feed to act, pinning the new version in.envand recreating the container (with health-checked rollback).
./update.sh check # report current vs latest + any advisory; change nothing
./update.sh update # update now (TARGET_VERSION=1.20.11 to pin a version)
./update.sh install # (re)schedule the daily run / uninstall to stop
UPDATE_POLICY (in .env or /etc/copyparty-update.conf) controls the
scheduled run:
| Policy | Behaviour |
|---|---|
latest (default) |
Update to the newest release whenever one exists. |
security |
Update only when the running version has a known advisory, to the patched release named in the feed. |
off |
Never change the running version (check/notify only). |
VC_FEED selects the feed (advisories-panic / advisories / advisories-all).
Update results reuse the ntfy config at /etc/ssh-notify.conf (same as the SSH
login notifier / host auto-update), when present.
Files
| File | Purpose |
|---|---|
docker-compose.yml |
caddy + copyparty; publishes SFTP/FTPS/passive ports. |
Caddyfile |
TLS + reverse proxy for the web UI/WebDAV (real-IP headers). |
copyparty.conf.example |
Config template (globals, security check, SFTP/FTPS, accounts, volumes). |
update.sh |
Security-notices-aware container updater. |
.env.example |
Stack tunables. |
deploy.sh / build.sh |
Self-contained installer + archive embedder. |
cloud-init.yml |
Fresh-VM bootstrap (harden SSH, then deploy). |
Notes
- No Anubis PoW gate — it would break WebDAV / API / upload clients.
- The web UI/WebDAV go through Caddy; SFTP/FTPS bypass it by design.
- DNS for
COPYPARTY_DOMAINmust resolve to the host and 80/443 be reachable before deploy for the LE cert to issue.