# Can I self-host Sync.com?

**YES** — it's called Syncthing. ONE COMMAND setup · ~10 minutes to running · 512 MB RAM minimum · $16/mo you stop paying ($192/yr on the Personal 1TB plan).

Syncthing authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/sync-com/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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"
```

## compose.local.yml

```yaml
# 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"
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

## Also evaluated

Ranked below Syncthing for this swap. The prompts above install Syncthing only.

- **Nextcloud** — 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 page this mirrors: https://caniselfhostit.com/self-host/sync-com/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
