What SFTPGo runs and where it keeps state
SFTPGo is one Go binary serving SFTP, FTP/S, WebDAV and HTTP/S, with a WebAdmin, a WebClient for end users and a REST API. A user's storage can be local disk, an encrypted local folder, S3-compatible storage, Google Cloud Storage, Azure Blob Storage or another SFTP server, per user or as a virtual folder with its own quota.
In the image the process runs as UID/GID 1000 in /var/lib/sftpgo, which holds the SSH host keys it generates on first start and the default SQLite file of admins, users and settings. Each user's home defaults to a folder named after them under /srv/sftpgo/data. The docs prefer SFTPGO_ environment variables over editing sftpgo.json, since they need no merge after an upgrade, and the variables can define up to ten bindings per service (internal/config/config.go).
Licence: the Community edition is AGPL-3.0-only with additional terms in the repository's NOTICE file. Our general reading, not legal advice: you may run and change it; anyone you hand copies to, changed or not, is owed the source under these same terms; and people who use a changed build of yours across the network are owed an offer of that changed source. NOTICE adds that if you build SFTPGo into a service reached over a network and configure it through your own front end, even unmodified, you owe clear attribution, no implied endorsement, and a link to its source. The README says the WebAdmin and WebClient theme is proprietary: allowed only inside SFTPGo, and not in derivative works without the SFTPGo team's permission.
Ports in this build
Defaults are from sftpgo.json; the host side is our layout. A mapping with no address publishes on 0.0.0.0 and [::], and Docker skips ufw for IPv4, so the IPv4 rules here only record intent. The [::] side is docker-proxy, which ufw filters, and the rules are IPv4-only on purpose: through docker-proxy all IPv6 clients would share the bridge gateway's address, and one defender ban would hit them all. IPv6 is in no default build of ours; if you add it, enable Docker IPv6 on the compose network before opening these ports to it.
| Service | Default | Host side here | Exposure |
|---|---|---|---|
| SFTP | 2022, on | 2022 | Public |
| WebAdmin, WebClient, REST API | 8080 plain HTTP, on | 127.0.0.1:8080 | SSH tunnel only |
| WebClient over HTTPS (binding 1) | not defined | 443 to 8443 | Public; WebAdmin and REST API off |
| ACME HTTP-01 answers | 80 | 80 to 8081 | Public; keep it open, every renewal answers here |
| WebDAV | port 0, off | optional, 10443 | Public if you add it |
| FTP/FTPS | port 0, off; passive 50000-50100 | optional, 2121 + 50000-50100 | Public if you add it |
The machine
A file server is bounded by disks and the port. Our enterprise dedicated servers are two-socket HPE ProLiant BL460c blades: two hot-plug bays at no charge with RAID 0 or 1, and 6-, 12- or 24-bay controllers (24 on the Xeon Scalable line) with RAID 5, 6 and 10 and drives to 15.36 TB SATA-SSD, behind a flash-backed cache. The port is 1 Gbps unmetered, to 100 Gbps on the Xeon Scalable line.
The iLO console still answers when a firewall rule locks out SSH, and the SLA has a failed disk or DIMM swapped within four hours of your report. Ready blades go live about 30 minutes after the order clears review; a build to your drive count takes 4 to 24 hours. Shapes not listed go through build to order. Cities are on our data centre pages.
Install: SFTP up, WebAdmin on loopback
Base: Ubuntu 24.04 with Docker Engine plus the compose plugin, each taken from the apt source that Docker publishes. Run as root (sudo -i). The block needs Engine 28, the first release where publishing to 127.0.0.1 is local-only (moby#45610), and does nothing if /opt/sftpgo/sftpgo.env exists, so a repeated paste cannot overwrite a live install.
v=$(docker version --format '{{.Server.Version}}' 2>/dev/null)
if [ "${v%%.*}" -ge 28 ] 2>/dev/null && [ ! -e /opt/sftpgo/sftpgo.env ]; then
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable &&
ufw allow proto tcp from 0.0.0.0/0 to any port 2022 && install -d -m 755 /opt/sftpgo &&
install -d -o 1000 -g 1000 -m 750 /opt/sftpgo/home /srv/sftpgo /srv/sftpgo/data &&
install -d -o 1000 -g 1000 -m 700 /srv/sftpgo/backups &&
install -m 600 /dev/null /opt/sftpgo/sftpgo.env &&
echo SFTPGO_COMMON__DEFENDER__ENABLED=true > /opt/sftpgo/sftpgo.env &&
cat > /opt/sftpgo/compose.yaml <<'EOF' && docker compose -f /opt/sftpgo/compose.yaml up -d
services:
sftpgo:
image: drakkan/sftpgo:v2.7.6
container_name: sftpgo
restart: unless-stopped
env_file: /opt/sftpgo/sftpgo.env
ports:
- "2022:2022"
- "127.0.0.1:8080:8080"
- "80:8081"
- "443:8443"
volumes:
- /srv/sftpgo:/srv/sftpgo
- /opt/sftpgo/home:/var/lib/sftpgo
EOF
else echo "STOP: needs Docker Engine 28+ and no earlier /opt/sftpgo/sftpgo.env"; fi
Our changes to the docker run in the Docker docs, in full: compose instead; the tag pinned to v2.7.6; a fixed name and restart: unless-stopped; bind mounts created at UID 1000, as those docs advise; the web port on 127.0.0.1; 80 and 443 published early with nothing behind them yet; and the defender, which ships disabled, turned on. It bans hosts that keep failing to log in.
First admin through an SSH tunnel
The web interfaces page is blunt about it: until an admin account exists, the setup screen is open to anyone who can reach WebAdmin. Here only the loopback can.
- On your workstation:
ssh -N -L 28022:127.0.0.1:8080 you@sftp-host, kept running for the session. - Browse to
http://127.0.0.1:28022/web/admin, create the admin with a long password, and set up two-factor login on the page that follows. - Under Users, add an account with a password or public key. Its home defaults to a folder with the same name under
/srv/sftpgo/data. - Test from outside:
sftp -P 2022 alice@sftp-host, then put and get a file.
Pre-creating the admin with create_default_admin and SFTPGO_DEFAULT_ADMIN_USERNAME / _PASSWORD also works, but that password then shows in docker inspect. For later access without a tunnel each time, a private overlay such as Headscale works.
TLS from SFTPGo's own ACME client
Create the DNS A record first, then, as root, set D and M and paste. The block opens 80 and 443 before anything asks Let's Encrypt, then runs the one-off sftpgo acme run the docs require for the first certificate; running it through docker exec in the live container is our adaptation. Its HTTP-01 listener sits on container port 8081 behind host port 80; the docs say to proxy port 80 when the listener is elsewhere, and the mapping is that proxy. Only after the certificate lands does it add the public HTTPS binding, with WebAdmin and the REST API off; the WebClient keeps working because its pages and file actions use their own /web/client/ routes (internal/httpd/httpd.go). Env-file changes reach the container only when it is recreated, which --force-recreate makes sure of.
D=files.example.com; M=you@example.com
E=/opt/sftpgo/sftpgo.env; C="docker compose -f /opt/sftpgo/compose.yaml"
if [ "$D" = files.example.com ] || [ "$M" = you@example.com ]; then echo "STOP: set D and M first"
elif ! getent ahostsv4 "$D" >/dev/null; then echo "STOP: $D does not resolve yet"
else
ufw allow proto tcp from 0.0.0.0/0 to any port 80,443 &&
{ grep -q '^SFTPGO_ACME__DOMAINS=' $E || printf '%s\n' "SFTPGO_ACME__DOMAINS=$D" \
"SFTPGO_ACME__EMAIL=$M" SFTPGO_ACME__CERTS_PATH=/var/lib/sftpgo/certs \
SFTPGO_ACME__HTTP01_CHALLENGE__PORT=8081 >> $E; } &&
$C up -d --force-recreate && sleep 5 && docker exec sftpgo sftpgo acme run &&
test -s /opt/sftpgo/home/certs/$D.crt &&
{ grep -q '^SFTPGO_HTTPD__BINDINGS__1__' $E || printf '%s\n' \
SFTPGO_HTTPD__BINDINGS__1__PORT=8443 SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS=true \
SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_ADMIN=false SFTPGO_HTTPD__BINDINGS__1__ENABLE_REST_API=false \
"SFTPGO_HTTPD__BINDINGS__1__CERTIFICATE_FILE=/var/lib/sftpgo/certs/$D.crt" \
"SFTPGO_HTTPD__BINDINGS__1__CERTIFICATE_KEY_FILE=/var/lib/sftpgo/certs/$D.key" >> $E; } &&
$C up -d --force-recreate && echo "ok: https://$D/web/client"
fi
The files land in certs_path named after the domain, .crt and .key (internal/acme/acme.go). After that the service renews by itself: it checks every 12 hours, renews 30 days before expiry by answering HTTP-01 on 8081 again, so port 80 stays open, and reloads the certificate in the HTTP, FTP and WebDAV servers.
WebDAV and FTPS, only when a client needs them
Both reuse that certificate. Each is an addition to /opt/sftpgo/sftpgo.env plus port lines in compose.yaml, then docker compose -f /opt/sftpgo/compose.yaml up -d --force-recreate.
- WebDAV over HTTPS:
SFTPGO_WEBDAVD__BINDINGS__0__PORT=10443,SFTPGO_WEBDAVD__BINDINGS__0__ENABLE_HTTPS=true, andSFTPGO_WEBDAVD__BINDINGS__0__CERTIFICATE_FILE/..._CERTIFICATE_KEY_FILEpointing at the two files in/var/lib/sftpgo/certs. Publish"10443:10443"and runufw allow proto tcp from 0.0.0.0/0 to any port 10443. - FTPS:
SFTPGO_FTPD__BINDINGS__0__PORT=2121,SFTPGO_FTPD__BINDINGS__0__TLS_MODE=1(TLS required on control and data connections), the same two certificate variables underFTPD, andSFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IPset to the public IPv4. The docs require that behind NAT, and Docker's bridge is NAT. - Passive data runs on 50000-50100 by default and must be reachable. Publish
"2121:2121"and"50000-50100:50000-50100", thenufw allow proto tcp from 0.0.0.0/0 to any port 2121,50000:50100. If you change the range withSFTPGO_FTPD__PASSIVE_PORT_RANGE__STARTand__END, change the published range and the ufw rule to match.
The project's own advice is to prefer SFTP and not enable FTP blindly.
Backups, restore and upgrades
Keep two things: /opt/sftpgo (compose file, env file, SQLite database, host keys, certificates) and /srv/sftpgo, the users' files. The archive covers the first with SFTPGo stopped, so the database copy is consistent, and names carry seconds. Copy /root/sftpgo-backups and /srv/sftpgo off the box with rsync -a (no --delete). Keeping the host keys spares clients a changed-fingerprint warning.
# Backup, as root. SFTPGo is stopped for as long as tar takes.
C="docker compose -f /opt/sftpgo/compose.yaml"; install -d -m 700 /root/sftpgo-backups
A=/root/sftpgo-backups/sftpgo-$(date +%F-%H%M%S).tar.gz
if $C stop; then tar -czf $A -C / opt/sftpgo; r=$?; $C start
[ $r -eq 0 ] && tar -tzf $A >/dev/null && echo "ok $A" || echo "STOP - no usable archive, discard $A"
else echo "STOP - SFTPGo did not stop, nothing archived"; fi
# Restore, as root (also on a new machine). Put the archive's real name in B.
B=/root/sftpgo-backups/sftpgo-YYYY-MM-DD-HHMMSS.tar.gz; O=/opt/sftpgo
if ! { [ -s "$B" ] && tar -tzf "$B" >/dev/null; }; then echo "STOP - $B is missing or unreadable"
elif [ -e $O.previous ]; then echo "STOP - $O.previous exists, deal with it first"
else { [ ! -e $O/compose.yaml ] || docker compose -f $O/compose.yaml down; } &&
{ [ ! -e $O ] || mv $O $O.previous; } && tar -xzf "$B" -C / &&
chown -R 1000:1000 $O/home && chmod 600 $O/sftpgo.env &&
docker compose -f $O/compose.yaml up -d && echo "restored; anything it replaced is in $O.previous"; fi
# Roll that restore back, as root.
O=/opt/sftpgo; X=$O.undone-$(date +%F-%H%M%S)
if [ -d $O.previous ] && [ ! -e $X ]; then docker compose -f $O/compose.yaml down &&
mv $O $X && mv $O.previous $O && docker compose -f $O/compose.yaml up -d && echo "rolled back; set aside in $X"
else echo "STOP - no $O.previous to roll back to"; fi
Once a restore checks out, delete /opt/sftpgo.previous so the next one is not blocked. To roll the files back too: docker compose -f /opt/sftpgo/compose.yaml stop && [ ! -e /srv/sftpgo.previous ] && mv /srv/sftpgo /srv/sftpgo.previous, rsync -a the copy back, chown -R 1000:1000 /srv/sftpgo, start again. On a new machine, as root: Docker Engine 28+, the install block's firewall lines (plus 80,443 if you use TLS), the archive in /root/sftpgo-backups, /srv/sftpgo brought back and given to 1000:1000 first, then the restore lines, which skip the stop and the move when nothing is installed.
To upgrade: take an archive, change the tag in compose.yaml, then docker compose -f /opt/sftpgo/compose.yaml pull && docker compose -f /opt/sftpgo/compose.yaml up -d. With the default update_mode of 0 the schema is upgraded on start, so the way back is the earlier archive, which carries the old tag.
Questions
Which port do SFTP clients connect to?
Port 2022, the image default, so clients need -P 2022 or the port field in WinSCP or FileZilla. The host's OpenSSH keeps 22; moving it to free 22 for SFTPGo leaves the console as your only way back if that goes wrong.
Will restarting the container cut uploads off mid-file?
By default, yes: SFTPGO_GRACE_TIME is 0 and SFTPGo stops at once. Set it to a number of seconds and new connections are refused while running ones finish. Docker's own stop timeout is 10 seconds, so raise stop_grace_period in the compose file above your grace time, or stop with docker stop --time 60 sftpgo.
Is the defender safe to leave on?
Yes for the public ports on IPv4, where the container sees each client's real address and bans the right host. One catch: by default the tunnelled WebAdmin reaches SFTPGo through docker-proxy, from the bridge gateway's address, so a run of your own wrong passwords there can ban the tunnel for the ban time (30 minutes by default). The same mechanism is why this page keeps SFTPGo's ports closed on IPv6 until IPv6 is switched on in Docker.