Use case · File transfer server

SFTPGo in Docker: SFTP first, a private WebAdmin, then TLS

To run SFTPGo in Docker, start the official drakkan/sftpgo image with two volumes owned by UID 1000, /srv/sftpgo for the files and /var/lib/sftpgo for the database, host keys and certificates, publish SFTP on port 2022, and keep the web admin on 127.0.0.1:8080 until you have created the first admin through an SSH tunnel. After that, SFTPGo's own ACME client can fetch a Let's Encrypt certificate for the WebClient, WebDAV and FTPS.

See the machines

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.

ServiceDefaultHost side hereExposure
SFTP2022, on2022Public
WebAdmin, WebClient, REST API8080 plain HTTP, on127.0.0.1:8080SSH tunnel only
WebClient over HTTPS (binding 1)not defined443 to 8443Public; WebAdmin and REST API off
ACME HTTP-01 answers8080 to 8081Public; keep it open, every renewal answers here
WebDAVport 0, offoptional, 10443Public if you add it
FTP/FTPSport 0, off; passive 50000-50100optional, 2121 + 50000-50100Public 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.

  1. On your workstation: ssh -N -L 28022:127.0.0.1:8080 you@sftp-host, kept running for the session.
  2. 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.
  3. 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.
  4. 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, and SFTPGO_WEBDAVD__BINDINGS__0__CERTIFICATE_FILE / ..._CERTIFICATE_KEY_FILE pointing at the two files in /var/lib/sftpgo/certs. Publish "10443:10443" and run ufw 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 under FTPD, and SFTPGO_FTPD__BINDINGS__0__FORCE_PASSIVE_IP set 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", then ufw allow proto tcp from 0.0.0.0/0 to any port 2121,50000:50100. If you change the range with SFTPGO_FTPD__PASSIVE_PORT_RANGE__START and __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.

Put the files on disks you control

Two-socket blades with hardware RAID and an iLO console, on an unmetered port, with no term.

See the machines

Related on Server Room

Other use cases