Can I self-host Sync.com?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called Syncthing. It takes one prompt, a 512 MB VPS, and about 10 minutes. That is $16 a month you stop paying Sync.com — $192 a year on the Personal 1TB plan.
Why people pay for Sync.com
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Sync.com sells cloud storage the company itself cannot read: the files are encrypted before they leave your device, and the keys stay with you. That is a genuinely different promise from most drive services, and what you pay for on top of it is the ordinary shape of a cloud drive built around it, apps on every platform, share links with expiry and passwords, a versioned trash, and a Canadian company that stays answerable for the copy it holds.
| Plan | List price | What it buys |
|---|---|---|
| Personal 150GB | $4/mo | 150 GB. $4 per month billed monthly, $3.50 per month billed annually, as the page showed them on 2026-08-06. |
| Personal 1TBthe plan this page prices against | $16/mo | 1 TB. Regular price $16 per month billed monthly, $12 per month billed annually. A 'Save 50% for 1 year' banner was live on 2026-08-06 showing $8 and $6, which is a first-year rate rather than the standing price. |
| Pro Solo 5TB | $32/mo | 5 TB. Regular price $32 per month billed monthly, $28 per month billed annually, discounted to $16 and $14 under the same first-year promotion. |
| Teams 1TB | $8/mo per seat | 1 TB per user, minimum 3 users. $8 per user per month billed monthly, $6 billed annually, halved under the first-year promotion. |
| Teams 2TB | $12/mo per seat | 2 TB per user, minimum 3 users. $12 per user per month billed monthly, $9 billed annually. |
| Teams 10TB | $18/mo per seat | 10 TB per user, minimum 3 users. $18 per user per month billed monthly, $15 billed annually. |
| Enterprise | quote only | Quote only, and the page states a 100-user minimum. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Syncthing
One project, named before the prompt, so you know what you are about to install.
Your files sync between your own devices, with the always-on server holding ciphertext it cannot read.
The only option here that keeps the zero-knowledge property rather than trading it for control. Syncthing's untrusted-device mode lets the always-on server you rent hold nothing but ciphertext, decrypted only on the machines that know the folder password, while your own devices sync plaintext straight to each other. That is the same arrangement Sync.com sells, with you as the operator. What you give up is everything built around the storage: no share links, no browser access to your files, no team permissions, and a feature upstream still marks beta.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Syncthingour pickONE COMMAND— setup effort 1 of 4
Your files sync between your own devices, with the always-on server holding ciphertext it cannot read.
The only option here that keeps the zero-knowledge property rather than trading it for control. Syncthing's untrusted-device mode lets the always-on server you rent hold nothing but ciphertext, decrypted only on the machines that know the folder password, while your own devices sync plaintext straight to each other. That is the same arrangement Sync.com sells, with you as the operator. What you give up is everything built around the storage: no share links, no browser access to your files, no team permissions, and a feature upstream still marks beta.
- NextcloudONE WEEKEND— setup effort 3 of 4
Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud.
The answer if what you actually miss is the cloud drive, not the cryptography. Nextcloud is server-centric: files live on a machine you run, a browser can open them, share links work, and there are calendars and documents alongside. The trade is exactly the one Syncthing refuses to make, because the server reads your files unless you add its end-to-end encryption layer, which is narrower than it sounds and does not cover the web interface. It is also a database, a PHP stack and a maintenance rhythm rather than one container.
The swap
You'd run
Syncthing
ONE COMMAND · ~10 min to running · 512 MB RAM
Sync.com Personal 1TB · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 512 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same Syncthing: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
320 lines · 14,991 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Syncthing 2.1.3 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.
Say this first, because it is the shape of the install. Syncthing is peer to peer: the user's
own computers hold the files and sync to each other. This server is one more peer, the always
awake one, and step 7 makes it untrusted, so what lands here is ciphertext this box cannot read
and no password that opens it is stored here. Upstream calls that feature beta.
Syncthing needs 512 MB of RAM available and 5 GB free on /srv, plus room for the synced files.
The image publishes amd64 and arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 512 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a
name that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/syncthing /srv/syncthing/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/syncthing/data /srv/syncthing/data/config /srv/syncthing/data/sync
ls -la /srv/syncthing /srv/syncthing/data
```
Assert: `backups` is owned by the login user, and `data`, `data/config` and `data/sync` by uid
`1000`, which the image runs Syncthing as. `data/config` holds config.xml, the device
certificate and the database; `data/sync` takes the ciphertext.
## 3. Secrets
One secret: the web GUI password. Generate it here, do not print it, and keep it out of your
summary and any log line. Hex, because it goes through a pipe into a container in step 7.
```bash
umask 077
cat > /srv/syncthing/.env <<EOF
GUI_USER=admin
GUI_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/syncthing/.env
umask 022
ls -l /srv/syncthing/.env
```
Assert: the file exists with mode `-rw-------`. Tell the user the login is `admin`, the password
is in /srv/syncthing/.env, they read it with `grep -F GUI_PASSWORD /srv/syncthing/.env`, and it
goes in their password manager now. The install's other password, the one that encrypts the
folder, is typed on the user's own computer in step 7 and never reaches this box.
## 4. compose.yml
```bash
cat > /srv/syncthing/compose.yml <<'EOF'
# Syncthing · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image ....... https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# image build ........ https://github.com/syncthing/syncthing/blob/v2.1.3/Dockerfile
# ports .............. https://docs.syncthing.net/users/firewall.html
#
# One service, no host networking: upstream's example uses it for LAN
# discovery, which a rented server has no use for, so 21027/udp is not
# published at all. No env_file either: the only secret is the web GUI
# password, which step 7 turns into a bcrypt hash in config.xml before the
# server listens. Digest read 2026-08-06; amd64, arm64, arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
syncthing:
image: syncthing/syncthing:2.1.3@sha256:8c8ff37ab6aa8be23b700648a90fa9412e214852e9fd6ea8477c8334792daec0
container_name: syncthing
hostname: syncthing-vps
restart: unless-stopped
environment:
# Image defaults; step 2 creates data/ owned by this uid.
PUID: "1000"
PGID: "1000"
# config.xml, the device certificate and the database all live here.
STHOMEDIR: /var/syncthing/config
# Container-internal; published on loopback below. The image also ships
# a HEALTHCHECK on /rest/noauth/health that compose inherits.
STGUIADDRESS: 0.0.0.0:8384
volumes:
- /srv/syncthing/data:/var/syncthing
ports:
# 8139 is loopback for Caddy; 22000 is the sync protocol itself, TCP
# and QUIC, on every interface on purpose.
- "127.0.0.1:8139:8384"
- "22000:22000/tcp"
- "22000:22000/udp"
EOF
cd /srv/syncthing && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, three published ports, no database container:
Syncthing's SQLite file sits beside config.xml.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-syncthing
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Syncthing · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.syncthing.net/users/reverseproxy.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It carries the
# web GUI only: the sync protocol on 22000 is a device-to-device TLS session
# that a reverse proxy cannot terminate.
<DOMAIN> {
encode zstd gzip
# Syncthing sets nosniff and SAMEORIGIN itself. HSTS and the referrer
# policy are added here: every request carries a session cookie for a
# device holding somebody's files.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# 8139 is the loopback port compose publishes. Not a container port, and
# not open in the firewall.
reverse_proxy 127.0.0.1:8139
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-syncthing, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it on its own.
## 6. Firewall
Five ports, and two of them are not Caddy's:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 22000/tcp
sudo ufw allow 22000/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp carries the GUI, 443/udp is
HTTP/3. 22000/tcp is upstream's sync protocol port and 22000/udp is that protocol over QUIC;
both open because a peer reaches this server directly, device certificate to device certificate,
without passing through Caddy. 21027/udp stays closed: upstream uses it for broadcast and
multicast discovery on a local segment, which this box shares with none of the user's devices.
8139 stays closed because compose binds it to loopback.
Assert: `Status: active`, rules for 80, 443/tcp, 443/udp, 22000/tcp and 22000/udp, none for 8139
or 21027. Tell the user that Docker publishes 22000 with its own iptables rules ahead of ufw's,
so `docker compose down`, not a deleted ufw rule, is what closes it.
## 7. Start and verify
Write the configuration before the server ever listens, so the GUI never answers without a
password. It reaches the container over a pipe, so it is in neither the process list nor the
shell history.
```bash
cd /srv/syncthing
docker compose pull
grep -F GUI_PASSWORD /srv/syncthing/.env | cut -d= -f2- | docker compose run --rm -T syncthing generate --gui-user admin --gui-password - --no-port-probing
sudo sed -i -E 's#<(globalAnnounceEnabled|localAnnounceEnabled|relaysEnabled|natEnabled|crashReportingEnabled)>true<#<\1>false<#; s#<urAccepted>0<#<urAccepted>-1<#' /srv/syncthing/data/config/config.xml
sudo chown 1000:1000 /srv/syncthing/data/config/config.xml
sudo grep -cE '>false</(globalAnnounce|localAnnounce|relays|nat|crashReporting)Enabled>|<urAccepted>-1<' /srv/syncthing/data/config/config.xml
```
Assert: the last command prints `6`. Those six are every connection this server would otherwise
open to infrastructure the user does not run: global discovery, LAN announcements, the community
relay pool, STUN and UPnP, crash and usage reporting. A box with a fixed public address and an
open 22000 needs none. Under 6, stop: the config is not the shape this prompt expects.
```bash
cd /srv/syncthing
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/rest/noauth/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/rest/noauth/health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/rest/system/status
curl -sS https://<DOMAIN>/ | grep -o 'Authentication Required'
curl -sSI https://<DOMAIN>/ | grep -iE 'x-syncthing-(version|id)'
```
Assert all five and print what you received. The loop ends on `200`. The health body contains
`"status": "OK"`. The unauthenticated API call prints `403`, the security assert here: the GUI
had a password before it had a first request. The page contains `Authentication Required`, the
heading over the `User` and `Password` boxes. The last command prints two headers, lowercased
over HTTP/2: a Syncthing version that must read `v2.1.3`, proving the pinned image is running,
and a long hyphenated Device ID to keep for the next step. If any miss, stop, run
`docker compose logs --tail 40 syncthing` and name the step: a container that exits at once is
step 2, a config directory the uid cannot write.
STOP: tell the user to do three things on the computer holding the files, and wait. Do not
continue until they confirm. One: install Syncthing from https://syncthing.net/downloads/ and
open its web GUI. Two: click `Add Remote Device`, paste the Device ID from above, name it, set
the address on the `Advanced` tab to `tcp://<DOMAIN>:22000`, and tick `Untrusted` there, whose
help text reads `All folders shared with this device must be protected by a password`. Three:
log in at https://<DOMAIN> and accept the `New Device` panel there.
STOP: tell the user to share one folder, and wait. Do not continue until they confirm. On their
own computer, in that folder's edit dialog under `Sharing`, they tick this server and type an
encryption password beside it. Say this plainly: that password is created and typed on their
computer only, never entered into the GUI here or written into any file on this box, and losing
it makes this copy unreadable forever. Have them drop a test file named `selfhost-check.txt`
into the folder before saving. A `New Folder` panel then appears in the GUI here, saying the
device `wants to share folder`. They accept it, set `Folder Path` to `/var/syncthing/sync`, and
set `Folder Type` to `Receive Encrypted` before saving. That choice is permanent: upstream
disables the dropdown once a folder exists.
Once both sides read `Up to Date`:
```bash
sudo grep -c 'type="receiveencrypted"' /srv/syncthing/data/config/config.xml
sudo find /srv/syncthing/data/sync -maxdepth 1 -type d -name '*.syncthing-enc' | wc -l
sudo find /srv/syncthing/data/sync -name 'selfhost-check.txt' | wc -l
```
Assert all three: `1`, then at least `1`, then `0`. The first says this server holds the folder
as receive-encrypted. The second says the data arrived, in the encrypted-name directory tree
upstream documents. The third is the one that matters: the file the user made is on this disk
and its name is not. A running container is not success; these three are. If the third is not
`0`, stop and say the folder holds plaintext, so `Folder Type` was not set when it was added.
The fix is to remove the folder here, delete and recreate /srv/syncthing/data/sync as in step 2,
and add it again.
## 8. First backup and restore
One archive: the device identity, the configuration, the database and the live Caddy site block.
```bash
cd /srv/syncthing
docker compose stop
sudo tar -czf /srv/syncthing/backups/syncthing-$(date +%F).tar.gz -C /srv/syncthing data/config compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/syncthing/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds, and
the container is stopped on purpose, because the database is SQLite and a SQLite file copied
mid-write is not a backup. The irreplaceable file in it is `data/config/key.pem`, this server's
Device ID: restoring it lets paired devices reconnect without approving a new one.
A backup on the same disk as the data is not a backup. Run this from the user's machine:
```bash
mkdir -p ~/backups/syncthing
scp vps:/srv/syncthing/backups/*.tar.gz ~/backups/syncthing/
```
To restore: `docker compose down`, `sudo rm -rf /srv/syncthing/data/config`, untar the archive
back into /srv/syncthing, `sudo chown -R 1000:1000 /srv/syncthing/data`, put the Caddy block
back if that was lost, then `docker compose up -d`. Not in the archive: data/sync, already a
second copy of the user's own files and useless without the folder password.
## 9. Updating later
New versions are listed at https://github.com/syncthing/syncthing/releases. The Docker Hub tag
drops the leading `v`, so `v2.1.4` is tag `2.1.4`. Back up first, then edit the image line in
/srv/syncthing/compose.yml to the new tag and its digest:
```bash
cd /srv/syncthing
docker compose pull
docker compose up -d
docker compose logs --tail 30 syncthing
```
Syncthing migrates its own database on the way up. Watch the log until it settles, then re-run
step 7's health and header checks before calling it done.
## 10. What will probably go wrong
The first time a folder finished syncing I opened /srv/syncthing/data/sync expecting my
documents, found a directory called `S.syncthing-enc` full of two-character folders holding
names like `K3P1VJO08DEQJ1DQJE0DLOMT068JJFD857L8ODM2TAKI3CC`, and spent a few minutes sure the
transfer had corrupted something. It had not. That is what a folder looks like from the
untrusted side, and it is the point: on that box I could not read my own files, so neither can
whoever takes it. Judge this by `Up to Date` and step 7's last three asserts.
## 11. Out of scope
- Do not change the folder here to `Send & Receive`, `Send Only` or `Receive Only`. Each means
this machine holds plaintext, the one thing the install prevents, and upstream will not let
you switch back.
- Do not put the folder encryption password on this server: not in .env, not in the GUI, not in
your summary. It belongs on the user's own computers.
- Do not turn global discovery, relaying or NAT traversal back on. This box has a fixed public
address and an open 22000, so it needs none of them.
- Do not open 21027/udp or switch the container to host networking. There is no local network
here to discover anything on.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Syncthing 2.1.3 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
Read this before step 1, because it is the shape of the whole install. Syncthing is peer to
peer: your own computers hold the files and sync to each other directly. This server is one more
peer, the always awake one, and step 7 configures it as an untrusted peer, so what lands on its
disk is ciphertext it cannot read. The password that opens that ciphertext is typed on your own
computer and never reaches this server, which means if you lose it nobody can recover the copy
here. Upstream calls the untrusted-device feature beta.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `512` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line. Syncthing itself is small; the disk figure is the floor before the
files you sync, which sit on top of it.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. An IP that is not your
server's usually means a proxying CDN sits in front of the record; turn that off for this
hostname, because the sync protocol on 22000 does not go through Caddy or a CDN at all and a
proxied A record will confuse the certificate rather than help it.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/syncthing /srv/syncthing/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/syncthing/data /srv/syncthing/data/config /srv/syncthing/data/sync
ls -la /srv/syncthing /srv/syncthing/data
```
You should see: `backups` owned by you, and `data`, `config` and `sync` owned by uid `1000`,
which `ls` prints as a bare number if no account on the box has that id.
If you do not: leave those three owned by 1000 on purpose. The image runs Syncthing as uid 1000
and hands the mount root to that uid on every start, so a directory owned by you would be one
Syncthing cannot write into. `data/config` will hold config.xml, the device certificate and the
database; `data/sync` is where the encrypted copy of your files lands in step 7.
## 3. Secrets
One secret is generated here, on the server: the web interface password. It goes straight into a
file only you can read. Hex rather than base64, because it travels through a pipe into a
container in step 7 and hex holds nothing a shell treats as special.
```bash
umask 077
cat > /srv/syncthing/.env <<EOF
GUI_USER=admin
GUI_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/syncthing/.env
umask 022
ls -l /srv/syncthing/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Your login is `admin`.
Read the password once with `grep -F GUI_PASSWORD /srv/syncthing/.env` and put it in your
password manager.
Do not paste that file, the password, or any command output containing it into this chat window.
That applies twice over to the other password in this install: the folder encryption password
you will create in step 7 on your own computer. It never reaches this server, and it must never
reach a chat window either, because it is the only thing standing between the ciphertext on that
disk and your files.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/syncthing/.env` and
carry on. If the file already existed from an earlier attempt this block has overwritten the
password, which is harmless before step 7 runs and means a locked-out login afterwards; in that
case re-run the `syncthing generate` line in step 7 and restart the container.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/syncthing/compose.yml <<'EOF'
# Syncthing · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image ....... https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# image build ........ https://github.com/syncthing/syncthing/blob/v2.1.3/Dockerfile
# ports .............. https://docs.syncthing.net/users/firewall.html
#
# One service, no host networking: upstream's example uses it for LAN
# discovery, which a rented server has no use for, so 21027/udp is not
# published at all. No env_file either: the only secret is the web GUI
# password, which step 7 turns into a bcrypt hash in config.xml before the
# server listens. Digest read 2026-08-06; amd64, arm64, arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
syncthing:
image: syncthing/syncthing:2.1.3@sha256:8c8ff37ab6aa8be23b700648a90fa9412e214852e9fd6ea8477c8334792daec0
container_name: syncthing
hostname: syncthing-vps
restart: unless-stopped
environment:
# Image defaults; step 2 creates data/ owned by this uid.
PUID: "1000"
PGID: "1000"
# config.xml, the device certificate and the database all live here.
STHOMEDIR: /var/syncthing/config
# Container-internal; published on loopback below. The image also ships
# a HEALTHCHECK on /rest/noauth/health that compose inherits.
STGUIADDRESS: 0.0.0.0:8384
volumes:
- /srv/syncthing/data:/var/syncthing
ports:
# 8139 is loopback for Caddy; 22000 is the sync protocol itself, TCP
# and QUIC, on every interface on purpose.
- "127.0.0.1:8139:8384"
- "22000:22000/tcp"
- "22000:22000/udp"
EOF
cd /srv/syncthing && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/syncthing/compose.yml` and paste again in one go. `port is already
allocated` at step 7 rather than here means something else on the box holds 22000; find it with
`sudo ss -ltnp | grep 22000` before changing anything. There is no database container in this
file: Syncthing keeps its own SQLite database beside config.xml under data/config.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-syncthing
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Syncthing · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.syncthing.net/users/reverseproxy.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It carries the
# web GUI only: the sync protocol on 22000 is a device-to-device TLS session
# that a reverse proxy cannot terminate.
<DOMAIN> {
encode zstd gzip
# Syncthing sets nosniff and SAMEORIGIN itself. HSTS and the referrer
# policy are added here: every request carries a session cookie for a
# device holding somebody's files.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# 8139 is the loopback port compose publishes. Not a container port, and
# not open in the firewall.
reverse_proxy 127.0.0.1:8139
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-syncthing /etc/caddy/Caddyfile`, reload,
and paste again. This site block carries the web interface only. The sync protocol on 22000 is a
device-to-device TLS session authenticated by device certificates, so it does not pass through
Caddy and there is no second site block to write for it.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 22000/tcp
sudo ufw allow 22000/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp`, `443/udp`, `22000/tcp` and
`22000/udp`, and no rule mentioning `8139` or `21027`.
If you do not: delete anything for `8139` with `sudo ufw delete allow 8139`. 8139 is bound to
127.0.0.1 by the compose file, so it has no business in the firewall. The two 22000 rules are
the ones worth understanding: that is upstream's sync protocol port, TCP and the same protocol
over QUIC on UDP, and a peer connects to this server on it directly, without passing through
Caddy, so no reverse proxy can stand in for it. 21027/udp stays closed because upstream uses it
for broadcast and multicast discovery on a local segment, which this server shares with none of
your devices. One more thing about the 22000 rules: Docker writes its own iptables rules for a
published port ahead of ufw's, so deleting them would not close the port. `docker compose down`
does. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.
## 7. Start and verify
The configuration is written before the server ever listens, so the web interface never answers
without a password. The password reaches the container over a pipe, so it lands in neither the
process list nor your shell history.
```bash
cd /srv/syncthing
docker compose pull
grep -F GUI_PASSWORD /srv/syncthing/.env | cut -d= -f2- | docker compose run --rm -T syncthing generate --gui-user admin --gui-password - --no-port-probing
sudo sed -i -E 's#<(globalAnnounceEnabled|localAnnounceEnabled|relaysEnabled|natEnabled|crashReportingEnabled)>true<#<\1>false<#; s#<urAccepted>0<#<urAccepted>-1<#' /srv/syncthing/data/config/config.xml
sudo chown 1000:1000 /srv/syncthing/data/config/config.xml
sudo grep -cE '>false</(globalAnnounce|localAnnounce|relays|nat|crashReporting)Enabled>|<urAccepted>-1<' /srv/syncthing/data/config/config.xml
```
You should see: a line about a generated key and calculated device id from the first command,
then `6` from the last.
If you do not: `6` under-counting means the generated config is not the shape this prompt
expects, and you should stop rather than start it. Those six lines are every connection this
server would otherwise open to infrastructure you do not run: global discovery, LAN
announcements, the community relay pool, STUN and UPnP, crash reporting and anonymous usage
reporting. A box with a fixed public address and an open 22000 needs none of them, and the
laptop you pair in a moment gets the address typed in by hand instead. The `chown` line is
there because `sudo sed` can leave the file owned by root, and a config.xml Syncthing cannot
write is a container that starts and then cannot save anything.
```bash
cd /srv/syncthing
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/rest/noauth/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/rest/noauth/health
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/rest/system/status
curl -sS https://<DOMAIN>/ | grep -o 'Authentication Required'
curl -sSI https://<DOMAIN>/ | grep -iE 'x-syncthing-(version|id)'
```
You should see, in order: the loop reaching `200`; a small JSON object containing
`"status": "OK"`; then `403`; then the literal `Authentication Required`; then two headers, a
Syncthing version reading `v2.1.3` and a Syncthing id followed by a long hyphenated Device ID.
Header names arrive lowercased over HTTP/2, which is why that last grep is case-insensitive.
Copy the Device ID somewhere; the next step needs it.
If you do not: the `403` is the one worth understanding. It means the API is up and refusing a
call with no credentials, which is what you want to see, because the password existed before the
first request ever arrived. A `200` there instead would mean the interface is open to anyone who
finds the hostname, and you should stop. `Authentication Required` is the heading above the
`User` and `Password` boxes on the first screen at https://<DOMAIN>. If the loop never reaches
`200`, run `docker compose logs --tail 40 syncthing`: a container that exits immediately is
usually step 2, a config directory uid 1000 cannot write, and a `502` from Caddy with a healthy
container is step 5. A running container is not success.
Now pair the computer that holds the files you want synced. This part happens in two places, and
you have to finish it before the asserts at the end of this step mean anything.
On that computer: install Syncthing from https://syncthing.net/downloads/ and open its web
interface. Click `Add Remote Device` and paste the Device ID from the header above. Give it a
name. On the `Advanced` tab of that dialog, set the address to `tcp://<DOMAIN>:22000` and tick
`Untrusted`, whose help text reads `All folders shared with this device must be protected by a
password, such that all sent data is unreadable without the given password.`
Then, in the interface at https://<DOMAIN>, log in as `admin` with the password from step 3. A
`New Device` panel appears saying the device `wants to connect`. Accept it with `Add Device`.
You should see: both sides showing the other as `Connected` within a minute or so.
If you do not: check `sudo ufw status verbose` again, because a device that never connects is
almost always 22000 closed. The address you typed is the reason global discovery is off on the
server: nothing announces this box anywhere, so the laptop reaches it because you told it where.
Now share the folder, and read this before you do. On your own computer, open the folder's edit
dialog, go to `Sharing`, tick this server, and type an encryption password in the field beside
it. That password is created and typed on your computer only. Do not enter it into the interface
on the server, do not put it in any file on the server, and do not paste it into this chat
window. Losing it makes the copy on that server unreadable forever, so it goes in your password
manager first. Drop a test file named `selfhost-check.txt` into the folder, then save.
In the interface at https://<DOMAIN> a `New Folder` panel now appears saying the device
`wants to share folder`. Accept it, set `Folder Path` to `/var/syncthing/sync`, and set
`Folder Type` to `Receive Encrypted` before you save. That last choice is permanent: upstream
disables the dropdown once a folder exists, and the only way back is to remove the folder and
add it again.
Once both sides read `Up to Date`:
```bash
sudo grep -c 'type="receiveencrypted"' /srv/syncthing/data/config/config.xml
sudo find /srv/syncthing/data/sync -maxdepth 1 -type d -name '*.syncthing-enc' | wc -l
sudo find /srv/syncthing/data/sync -name 'selfhost-check.txt' | wc -l
```
You should see: `1`, then at least `1`, then `0`.
If you do not: the third number is the one that matters, and anything but `0` means the server
is holding your files in the clear. That happens when `Folder Type` was left at `Send & Receive`
in the accept dialog. Remove the folder in the server interface, then
`sudo rm -rf /srv/syncthing/data/sync`, recreate it exactly as in step 2, and add the folder
again with the type set this time. The second number being `0` while the first is `1` usually
means nothing has synced yet, so give it a minute. A running container is not success; these
three numbers are.
## 8. First backup and restore
One archive: the device identity, the configuration, the database and the live Caddy site block.
```bash
cd /srv/syncthing
docker compose stop
sudo tar -czf /srv/syncthing/backups/syncthing-$(date +%F).tar.gz -C /srv/syncthing data/config compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/syncthing/backups/
```
You should see: one file, a few hundred kilobytes on a fresh install. The container goes down for
about five seconds on purpose, because the database is SQLite and a SQLite file copied mid-write
is not a backup.
If you do not: an archive under a kilobyte means `data/config` was empty, so step 7 never ran.
The irreplaceable file inside is `data/config/key.pem`. That is this server's Device ID, and
restoring it is what lets every paired device reconnect without approving a new device.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/syncthing
scp vps:/srv/syncthing/backups/*.tar.gz ~/backups/syncthing/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/syncthing/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is a test file:
```bash
cd /srv/syncthing
docker compose down
sudo rm -rf /srv/syncthing/data/config
sudo tar -xzf /srv/syncthing/backups/syncthing-$(date +%F).tar.gz -C /srv/syncthing data/config
sudo chown -R 1000:1000 /srv/syncthing/data
docker compose up -d
sleep 15
curl -sSI https://<DOMAIN>/ | grep -i x-syncthing-id
```
You should see: the same Device ID the earlier command printed. Same identity, same paired
devices, nothing to approve again.
If you do not: a different Device ID means the archive did not contain `data/config/key.pem` and
Syncthing generated a fresh identity, which every peer will treat as a stranger. Stop and take a
new backup before going any further. What is not in that archive is /srv/syncthing/data/sync,
because it is already a second copy of what is on your own devices, and it is ciphertext only
the folder password opens. Upstream ships `syncthing decrypt` for reading it back on a trusted
computer if it ever becomes the last copy you have.
## 9. Updating later
New versions are listed at https://github.com/syncthing/syncthing/releases. The Docker Hub tag
drops the leading `v`, so release `v2.1.4` is image tag `2.1.4`. Take a backup first, then edit
the `image:` line in /srv/syncthing/compose.yml to the new tag and its digest.
```bash
cd /srv/syncthing
docker compose pull
docker compose up -d
docker compose logs --tail 30 syncthing
```
You should see: a startup line naming the new version, then the folder going to `Up to Date`,
and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Syncthing
migrates its own database on the way up, so a first start after an update can take longer than
usual. Re-run the health and header checks from step 7 before you call the update done, and
check that the folder still reads `Up to Date` on both sides.
## 10. What will probably go wrong
The first time a folder finished syncing I opened /srv/syncthing/data/sync expecting my
documents, found a directory called `S.syncthing-enc` full of two-character folders holding
names like `K3P1VJO08DEQJ1DQJE0DLOMT068JJFD857L8ODM2TAKI3CC`, and spent a few minutes sure the
transfer had corrupted something. It had not. That is what a folder looks like from the
untrusted side, and it is the point: on that box I could not read my own files, so neither can
whoever takes it. Judge this by `Up to Date` in the interface and step 7's last three asserts.
## 11. Out of scope
- Do not change the folder on this server to `Send & Receive`, `Send Only` or `Receive Only`.
Each means this machine holds plaintext, the one thing the install prevents, and upstream will
not let you switch back.
- Do not put the folder encryption password on this server: not in .env, not in the interface,
not in this chat window. It belongs on your own computers.
- Do not turn global discovery, relaying or NAT traversal back on. This box has a fixed public
address and an open 22000, so it needs none of them.
- Do not open 21027/udp or switch the container to host networking. There is no local network
here to discover anything on.307 lines · 14,999 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Syncthing 2.1.3 under ~/selfhost/syncthing, with its interface at
http://localhost:8139.
## 1. Preflight
Say this before step 2 runs, because it decides whether the user wants this at all. Syncthing is
peer to peer: this computer becomes one device in a group that syncs directly, nothing in the
middle keeping a copy. Two devices exchange changes only while both are awake, so a laptop that
spends the night closed is one whose files reach the phone in the morning.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Syncthing needs 512 MB of RAM available and
5 GB free on the home disk, plus room for the synced files, and publishes amd64 and arm64. Under
either floor, print both and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/syncthing/data/config ~/selfhost/syncthing/data/sync ~/selfhost/syncthing/backups
if [ "$(uname -s)" = "Linux" ]; then
sudo chown -R 1000:1000 ~/selfhost/syncthing/data
fi
ls -la ~/selfhost/syncthing ~/selfhost/syncthing/data
```
Assert: `ls -la` shows `data` and `backups`, with `config` and `sync` inside `data`. The
container runs as uid 1000, so on Linux `data` is chowned to match; on macOS and Windows Docker
Desktop handles that and the fence is a no-op. `data/config` holds config.xml, the device key
and the database; `data/sync` is what syncs.
## 4. Secrets
One secret: the web GUI password. Generate it here, print it nowhere, keep it out of your
summary and any log. Hex, because it goes through a pipe into a container in step 7.
```bash
umask 077
cat > ~/selfhost/syncthing/.env <<EOF
GUI_USER=admin
GUI_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/syncthing/.env
umask 022
ls -l ~/selfhost/syncthing/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same on all three systems. Tell the user the login is `admin`, the password is in
~/selfhost/syncthing/.env, they read it with `grep -F GUI_PASSWORD ~/selfhost/syncthing/.env`,
and it goes in their password manager now. On Windows those mode bits are advisory: NTFS does
not enforce them, and the boundary is the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/syncthing/compose.yml <<'EOF'
# Syncthing · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker image ....... https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# image build ........ https://github.com/syncthing/syncthing/blob/v2.1.3/Dockerfile
# configuration ...... https://docs.syncthing.net/users/config.html
#
# One service on the computer you are sitting at, with relative paths so that
# one file works on macOS, Linux and Windows and you can open the synced folder
# in Finder or Explorer. Only the GUI port is published, and only on loopback:
# 22000 is not published at all, so nothing dials in and this instance dials
# out instead. No env_file: the only secret is the web GUI password, and step 7
# leaves it in config.xml as a bcrypt hash. Digest read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
syncthing:
image: syncthing/syncthing:2.1.3@sha256:8c8ff37ab6aa8be23b700648a90fa9412e214852e9fd6ea8477c8334792daec0
container_name: syncthing
hostname: syncthing-local
restart: unless-stopped
environment:
# Image defaults; on Linux step 3 chowns ./data to match.
PUID: "1000"
PGID: "1000"
# config.xml, the device certificate and the database all live here.
STHOMEDIR: /var/syncthing/config
# Container-internal; published on loopback below. The image also ships
# a HEALTHCHECK on /rest/noauth/health.
STGUIADDRESS: 0.0.0.0:8384
volumes:
- ./data:/var/syncthing
ports:
# Loopback only: no other device on the wifi reaches 8139.
- "127.0.0.1:8139:8384"
EOF
cd ~/selfhost/syncthing && docker compose config >/dev/null && echo "compose OK"
```
Assert: `compose OK`. One service, one published port, no database.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve and no
public name to certify; browsers treat http://localhost as a secure context anyway, so pages
needing crypto still work, and nothing goes beyond loopback for a firewall to close.
8139 is bound to 127.0.0.1, this computer only: not the user's phone, not a laptop on the same
wifi, not anyone on the internet. 22000, the sync protocol port, is not published at all, and
that is the part worth understanding. Nothing dials in here, so Syncthing syncs by dialling out,
directly to a reachable peer and otherwise through the community relay pool upstream runs, which
is why discovery and relaying stay on, unlike on the server. Confirm it:
```bash
grep -c '127.0.0.1:8139:8384' ~/selfhost/syncthing/compose.yml
```
Assert: `1`, and no other published port anywhere in the file.
## 7. Start and verify
Write the configuration before the server listens, so the interface never answers without a
password. It reaches the container over a pipe, out of the process list and the history.
```bash
cd ~/selfhost/syncthing
docker compose pull
grep -F GUI_PASSWORD ~/selfhost/syncthing/.env | cut -d= -f2- | docker compose run --rm -T syncthing generate --gui-user admin --gui-password - --no-port-probing
docker compose run --rm -T --entrypoint /bin/entrypoint.sh syncthing /bin/sh -c 'sed -i -e "s|<crashReportingEnabled>true<|<crashReportingEnabled>false<|" -e "s|<urAccepted>0<|<urAccepted>-1<|" /var/syncthing/config/config.xml && grep -cE ">false</crashReportingEnabled>|<urAccepted>-1<" /var/syncthing/config/config.xml'
```
Assert: that prints `2`, the two reports Syncthing would otherwise send to infrastructure the
user does not run. The edit runs inside the container, as the uid that owns the file, so it
needs no sudo and works the same on all three systems. Under 2, stop.
```bash
cd ~/selfhost/syncthing
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8139/rest/noauth/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8139/rest/noauth/health
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8139/rest/system/status
curl -sS http://localhost:8139/ | grep -o 'Authentication Required'
curl -sSI http://localhost:8139/ | grep -iE 'x-syncthing-(version|id)'
```
Assert all five and print what you received. The loop ends on `200`. The health body contains
`"status": "OK"`. The unauthenticated API call prints `403`, the security assert here: the
interface had a password before its first request. The page contains `Authentication Required`,
the heading over the `User` and `Password` boxes. The last command prints two headers, possibly
lowercased: a Syncthing version that must read `v2.1.3`, proving the pinned image is running,
and a long hyphenated Device ID to keep for the next step. If any miss, stop, run
`docker compose logs --tail 40 syncthing` and name the step: a container that exits at once is
step 3, a config directory the uid cannot write. `port is already allocated` means something
else holds 8139; find it with `lsof -nP -iTCP:8139 -sTCP:LISTEN`.
STOP: tell the user to pair a second device, and wait. Do not continue until they confirm. They
install Syncthing on the other computer or phone from https://syncthing.net/downloads/ and use
`Add Remote Device` there with the Device ID printed above. Then they log in at
http://localhost:8139 as `admin` and accept the `New Device` panel. Both sides add the other
before anything connects.
STOP: tell the user to share the folder, and wait. Do not continue until they confirm. At
http://localhost:8139 they click `Add Folder`, set `Folder Path` to `/var/syncthing/sync`, give
it a label, tick the other device under `Sharing`, and save; on the other device they accept the
`New Folder` panel. Have them drop a file called `selfhost-check.txt` into
~/selfhost/syncthing/data/sync.
Once both sides read `Up to Date`:
```bash
ls -la ~/selfhost/syncthing/data/sync
docker compose exec -T syncthing grep -c 'type="sendreceive"' /var/syncthing/config/config.xml
```
Assert: the listing shows `selfhost-check.txt` and a `.stfolder` marker directory, and the grep
prints at least `1`. The marker is how Syncthing tells an empty folder from an unmounted disk.
A running container is not success; these two are. A missing `.stfolder` means the folder was
never added, so go back to the second STOP.
## 8. First backup and restore
One archive: the device identity, the configuration and the database. It is small: the synced
files are on the other device already.
```bash
cd ~/selfhost/syncthing
docker compose stop
tar -C ~/selfhost/syncthing -czf ~/selfhost/syncthing/backups/syncthing-$(date +%F).tar.gz data/config compose.yml .env
docker compose start
ls -lh ~/selfhost/syncthing/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds, and
the container is stopped on purpose, because the database is SQLite and a file copied mid-write
is not a backup. The irreplaceable thing inside is `data/config/key.pem`, this machine's Device
ID; restoring it lets paired devices reconnect without a new one.
That archive is on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a
Windows drive is `/d/Backups`, not `D:\Backups`. Assert: the user confirms the file is there.
If they have nowhere, say this install has no backup.
To restore: `docker compose down`, `rm -rf ~/selfhost/syncthing/data/config`, untar the archive
into ~/selfhost/syncthing, chown `data` back to 1000 on Linux, then `docker compose up -d`.
Files under data/sync return from the other device.
## 9. Updating later
New versions are listed at https://github.com/syncthing/syncthing/releases. The Docker Hub tag
drops the leading `v`, so `v2.1.4` is tag `2.1.4`. Back up first, then put the new tag and its
digest in compose.yml:
```bash
cd ~/selfhost/syncthing
docker compose pull
docker compose up -d
docker compose logs --tail 30 syncthing
```
Syncthing migrates its own database on the way up. Watch the log until it settles, then re-run
step 7's checks before calling the update done.
## 10. What will probably go wrong
I edited a document on my laptop, closed the lid, and expected it on the other machine by the
time I got there. It was not, and I spent a while hunting the interface for an error. There was
none: with no inbound port on either side, both devices have to be awake at the same moment for
anything to move, and mine had not overlapped in two days. Docker Desktop also does not always
start with the session, so after a reboot run `docker compose up -d` in ~/selfhost/syncthing
before concluding anything broke.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not publish 22000 or rebind 8139 to 0.0.0.0 so another device can dial in. Dialling out is
what this path uses instead.
- Do not add a folder outside ~/selfhost/syncthing until the first has synced cleanly for a
day. A misconfigured one deletes files on the other device.compose.local.ymlthe services, pinned · local layout35 lines
# Syncthing · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker image ....... https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# image build ........ https://github.com/syncthing/syncthing/blob/v2.1.3/Dockerfile
# configuration ...... https://docs.syncthing.net/users/config.html
#
# One service on the computer you are sitting at, with relative paths so that
# one file works on macOS, Linux and Windows and you can open the synced folder
# in Finder or Explorer. Only the GUI port is published, and only on loopback:
# 22000 is not published at all, so nothing dials in and this instance dials
# out instead. No env_file: the only secret is the web GUI password, and step 7
# leaves it in config.xml as a bcrypt hash. Digest read 2026-08-06.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
syncthing:
image: syncthing/syncthing:2.1.3@sha256:8c8ff37ab6aa8be23b700648a90fa9412e214852e9fd6ea8477c8334792daec0
container_name: syncthing
hostname: syncthing-local
restart: unless-stopped
environment:
# Image defaults; on Linux step 3 chowns ./data to match.
PUID: "1000"
PGID: "1000"
# config.xml, the device certificate and the database all live here.
STHOMEDIR: /var/syncthing/config
# Container-internal; published on loopback below. The image also ships
# a HEALTHCHECK on /rest/noauth/health.
STGUIADDRESS: 0.0.0.0:8384
volumes:
- ./data:/var/syncthing
ports:
# Loopback only: no other device on the wifi reaches 8139.
- "127.0.0.1:8139:8384"agent-readable mirror: /self-host/sync-com.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned37 lines
# Syncthing · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image ....... https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# image build ........ https://github.com/syncthing/syncthing/blob/v2.1.3/Dockerfile
# ports .............. https://docs.syncthing.net/users/firewall.html
#
# One service, no host networking: upstream's example uses it for LAN
# discovery, which a rented server has no use for, so 21027/udp is not
# published at all. No env_file either: the only secret is the web GUI
# password, which step 7 turns into a bcrypt hash in config.xml before the
# server listens. Digest read 2026-08-06; amd64, arm64, arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
syncthing:
image: syncthing/syncthing:2.1.3@sha256:8c8ff37ab6aa8be23b700648a90fa9412e214852e9fd6ea8477c8334792daec0
container_name: syncthing
hostname: syncthing-vps
restart: unless-stopped
environment:
# Image defaults; step 2 creates data/ owned by this uid.
PUID: "1000"
PGID: "1000"
# config.xml, the device certificate and the database all live here.
STHOMEDIR: /var/syncthing/config
# Container-internal; published on loopback below. The image also ships
# a HEALTHCHECK on /rest/noauth/health that compose inherits.
STGUIADDRESS: 0.0.0.0:8384
volumes:
- /srv/syncthing/data:/var/syncthing
ports:
# 8139 is loopback for Caddy; 22000 is the sync protocol itself, TCP
# and QUIC, on every interface on purpose.
- "127.0.0.1:8139:8384"
- "22000:22000/tcp"
- "22000:22000/udp"Caddyfilethe hostname and TLS28 lines
# Syncthing · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.syncthing.net/users/reverseproxy.html and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It carries the
# web GUI only: the sync protocol on 22000 is a device-to-device TLS session
# that a reverse proxy cannot terminate.
<DOMAIN> {
encode zstd gzip
# Syncthing sets nosniff and SAMEORIGIN itself. HSTS and the referrer
# policy are added here: every request carries a session cookie for a
# device holding somebody's files.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# 8139 is the loopback port compose publishes. Not a container port, and
# not open in the firewall.
reverse_proxy 127.0.0.1:8139
}install.shthe same install, no agent186 lines
#!/usr/bin/env bash
# Syncthing · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=sync.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/syncthing/syncthing/blob/v2.1.3/README-Docker.md
# https://docs.syncthing.net/users/config.html
# https://docs.syncthing.net/users/firewall.html
# https://docs.syncthing.net/users/untrusted.html
#
# One secret is generated here, on this machine: the web GUI password. It goes
# into /srv/syncthing/.env with mode 600, is never printed, and becomes a bcrypt
# hash inside config.xml before the server ever listens.
#
# The second password this install needs is deliberately not generated here. The
# folder encryption password is typed on your own computer when you share the
# folder, and it must never be stored on this server. That is what makes this
# box an untrusted peer rather than merely a remote one.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/syncthing}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. sync.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 512 ] || die "only ${avail_mb} MB of RAM available; this install wants 512 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB before any synced files"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The image runs Syncthing as uid 1000 and hands the mount root to that uid on
# every start, so these three directories are created owned by it.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/data" "$APP_DIR/data/config" "$APP_DIR/data/sync"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: the value travels through a pipe into a container in
# section 6 and hex holds nothing a shell treats as special. Read it later with
# grep -F GUI_PASSWORD /srv/syncthing/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
GUI_USER=admin
GUI_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
#
# This hostname carries the web GUI only. The sync protocol on 22000 is a
# device-to-device TLS session that no reverse proxy can terminate.
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-syncthing"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: five open, and 8139 and 21027 are not among them --------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3, 22000/tcp and 22000/udp for the"
echo "==> sync protocol, which peers reach directly. 8139 and 21027 stay closed."
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 22000/tcp
sudo ufw allow 22000/udp
sudo ufw status verbose
fi
# --- 6. Write the config, then start -----------------------------------------
#
# The GUI password becomes a bcrypt hash in config.xml before the server ever
# listens, so there is no window in which the interface answers without one. The
# six sed edits turn off every outbound connection this box would otherwise make
# to infrastructure you do not run: global discovery, LAN announcements, the
# community relay pool, STUN and UPnP, crash reporting and usage reporting. A
# server with a fixed public address and an open 22000 needs none of them.
docker compose pull
if [ ! -f "$APP_DIR/data/config/config.xml" ]; then
grep -F GUI_PASSWORD "$APP_DIR/.env" | cut -d= -f2- \
| docker compose run --rm -T syncthing generate --gui-user admin --gui-password - --no-port-probing
sudo sed -i -E 's#<(globalAnnounceEnabled|localAnnounceEnabled|relaysEnabled|natEnabled|crashReportingEnabled)>true<#<\1>false<#; s#<urAccepted>0<#<urAccepted>-1<#' "$APP_DIR/data/config/config.xml"
sudo chown 1000:1000 "$APP_DIR/data/config/config.xml"
fi
knobs="$(sudo grep -cE '>false</(globalAnnounce|localAnnounce|relays|nat|crashReporting)Enabled>|<urAccepted>-1<' "$APP_DIR/data/config/config.xml" || true)"
[ "$knobs" = "6" ] || die "config.xml has ${knobs} of the 6 expected settings. Stop: it is not the shape this script wrote."
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/rest/noauth/health"
for _ in $(seq 1 24); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/rest/noauth/health" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "/rest/noauth/health answered ${code:-nothing}. Check: docker compose logs --tail 40 syncthing"
curl -sS "https://${DOMAIN_HOST}/rest/noauth/health" | grep -q '"status": "OK"' \
|| die "the health endpoint answered 200 without status OK. Check: docker compose logs --tail 40 syncthing"
# The API must refuse an unauthenticated call. Upstream answers 403 when no
# session cookie, API key or basic auth is present.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/rest/system/status" || true)"
[ "$unauth" = "403" ] || die "an unauthenticated API call returned ${unauth}, not 403. Stop and investigate."
curl -sS "https://${DOMAIN_HOST}/" | grep -q 'Authentication Required' \
|| die "the first screen does not carry 'Authentication Required'. Stop and investigate."
running="$(curl -sSI "https://${DOMAIN_HOST}/" | awk 'tolower($1)=="x-syncthing-version:"{print $2}' | tr -d '\r')"
[ "$running" = "v2.1.3" ] || die "the running version is ${running:-unknown}, not v2.1.3"
DEVICE_ID="$(curl -sSI "https://${DOMAIN_HOST}/" | awk 'tolower($1)=="x-syncthing-id:"{print $2}' | tr -d '\r')"
[ -n "$DEVICE_ID" ] || die "the server did not report a Device ID"
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/syncthing-${STAMP}.tar.gz" -C "$APP_DIR" data/config compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/syncthing-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Syncthing is answering at https://${DOMAIN_HOST}
Device ID: ${DEVICE_ID}
1. Your login is admin. The password is in $APP_DIR/.env, mode 600. Read
it with
grep -F GUI_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here.
2. Nothing syncs yet. On the computer that holds your files, install
Syncthing from https://syncthing.net/downloads/, add the Device ID
above as a remote device, set its address to tcp://${DOMAIN_HOST}:22000,
and tick Untrusted on the Advanced tab. Approve it here as well.
3. Then share one folder from that computer and set an encryption password
for this device. Create that password on your own computer. Never type
it into the interface on this server and never write it into a file
here: that is the whole point, and losing it makes the copy on this box
unreadable forever.
4. When this server offers to add the folder, set Folder Path to
/var/syncthing/sync and Folder Type to Receive Encrypted before saving.
That choice cannot be changed afterwards. Check it landed with
sudo grep -c 'type="receiveencrypted"' $APP_DIR/data/config/config.xml
5. First backup written to $APP_DIR/backups: config.xml, the device key,
the database and the live Caddy block. It is on the same disk as the
data, which is not a backup. Copy it somewhere else tonight. The file
that matters most in it is data/config/key.pem, this server's identity.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Sync.com.
- The server is not the copy of record. Syncthing is peer to peer: your own devices hold the files and reconcile with each other, and the rented box is one more peer that happens to never sleep. Delete a file on your laptop and it goes on every device that is paired, so file versioning per folder, not the server, is what saves you from yourself.
- The zero-knowledge property is real here, and it costs you the recovery story. The install configures the server folder as Receive Encrypted, so what sits on that disk is ciphertext under a password you typed on your own machine and never sent to the server. Nobody can reset it, nobody can recover it, and upstream still labels the feature beta.
- That choice is one-way. Receive Encrypted can only be set when a folder is first added, and upstream disables the dropdown afterwards. Getting it wrong means removing the folder, deleting the data on the server and adding it again, so it is worth reading step 7 twice.
- Your devices talk to community infrastructure by default. Global discovery, relaying, NAT traversal and crash reporting are all on out of the box and point at servers the Syncthing project runs. The server install turns all of them off, because a box with a public address needs none; the install on your own computer leaves discovery and relaying on and says so, because with no inbound port they are how a connection gets made at all.
- No web sharing links, no browser access to your files, no team folders with permissions, and no versioned trash you can browse from a phone. Syncthing moves files between devices you own; every one of those is what Sync.com sells on top of that.
Where this came from
“It is possible to set a password on a folder when it's shared with another device. Data sent will be encrypted by this password, and data received will be decrypted by the same password.”
- A folder shared with an untrusted device is encrypted with a password held only by the trusted devices, and on the untrusted device the folder type must be set to Receive Encrypted. source
- Upstream marks the untrusted-device feature with a warning that it should still be considered beta or testing only. source
- Global discovery, local discovery, relaying and NAT traversal all default to true, so a device talks to Syncthing's community discovery, relay and STUN servers unless those settings are turned off. source
- The sync protocol uses 22000/TCP and 22000/UDP for QUIC, while 21027/UDP carries local discovery broadcasts and multicasts on the local segment only. source
- syncthing generate accepts --gui-user and --gui-password, reading the password from standard input when the argument is a single dash, and stores it as a bcrypt hash. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Sync.com?
Not Sync.com itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Syncthing. Your files sync between your own devices, with the always-on server holding ciphertext it cannot read. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Sync.com?
Syncthing. Your files sync between your own devices, with the always-on server holding ciphertext it cannot read. The only option here that keeps the zero-knowledge property rather than trading it for control. Syncthing's untrusted-device mode lets the always-on server you rent hold nothing but ciphertext, decrypted only on the machines that know the folder password, while your own devices sync plaintext straight to each other. That is the same arrangement Sync.com sells, with you as the operator. What you give up is everything built around the storage: no share links, no browser access to your files, no team permissions, and a feature upstream still marks beta. Also evaluated and ranked below it: Nextcloud. The prompt on this page installs Syncthing only. Syncthing is MPL-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Sync.com?
512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Syncthing itself is free and MPL-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Sync.com Personal 1TB, $16/mo — $192 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Syncthing install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Syncthing on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Syncthing on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer this is one peer among your devices rather than the always-on one, so two machines exchange changes only while both are awake and there is no untrusted server holding an encrypted copy for you. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.