Donut Sync is the small server that backs cross-device sync for profiles, proxies, groups, extensions, and (optionally) the full profile browser data. Sync through Donut's hosted cloud is a paid feature, but if you run the server yourself it is completely free. Your data never leaves storage you control.

The server is a single container that talks to any S3-compatible bucket. You can run the bundled MinIO for a fully local setup, or point it at AWS S3, Cloudflare R2, Backblaze B2, or anything else that speaks the S3 API.

## What you need

- [Docker](https://docs.docker.com/get-docker/) with Docker Compose (`docker compose`, included in modern Docker Desktop / Engine).
- An S3-compatible bucket. The quick start below runs MinIO for you, so you do not need one to start.

## Quick start

Create a `docker-compose.yml`. This runs Donut Sync plus a local MinIO, so everything stays on one machine:

```yaml
services:
  donut-sync:
    image: donutbrowser/donut-sync:latest
    ports:
      - "3929:3929"
    environment:
      # A long random secret. Clients present this as their bearer token.
      SYNC_TOKEN: change-me-to-a-long-random-secret
      PORT: "3929"
      # How the server itself reaches MinIO, over the compose network.
      S3_ENDPOINT: http://minio:9000
      # How Donut Browser reaches MinIO. This must be an address your devices
      # can open. `minio:9000` only resolves inside the compose network, so
      # without this every file transfer fails. See "How storage URLs work".
      S3_PUBLIC_ENDPOINT: http://localhost:9000
      S3_REGION: us-east-1
      S3_ACCESS_KEY_ID: minioadmin
      S3_SECRET_ACCESS_KEY: minioadmin
      S3_BUCKET: donut-sync
      S3_FORCE_PATH_STYLE: "true"
    depends_on:
      minio:
        condition: service_healthy
    restart: unless-stopped

  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    ports:
      # Donut Browser downloads and uploads profile files straight from MinIO,
      # so this port has to be reachable from your devices.
      - "9000:9000"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    # Optional: publish the MinIO console to inspect stored objects.
    # ports:
    #   - "9001:9001"

volumes:
  minio_data:
```

Start it:

```bash
docker compose up -d
```

The bucket named in `S3_BUCKET` is created automatically on first connect, so there is no manual setup step.

## Verify it is running

```bash
# Liveness: the server process is up.
curl http://localhost:3929/health
# {"status":"ok"}

# Readiness: the server can reach S3, and the address it hands to clients.
curl http://localhost:3929/readyz
# {"status":"ready","s3":true,"storageEndpoint":"http://localhost:9000"}
```

If `/readyz` returns HTTP 503 with `{"status":"not ready","s3":false}`, the server started but cannot reach your storage. Re-check the `S3_*` values and that the bucket endpoint is reachable from the container.

## Environment variables

| Variable               | Required              | Default                 | Description                                                                                                                                         |
| ---------------------- | --------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SYNC_TOKEN`           | Yes, for self-hosting | none                    | Bearer token clients send in the `Authorization` header. Use a long random value.                                                                   |
| `PORT`                 | No                    | `3929`                  | Port the server listens on.                                                                                                                         |
| `S3_ENDPOINT`          | No                    | `http://localhost:8987` | S3-compatible endpoint URL, as reached **by the server**. Set this for every real deployment (see below). The default targets a local dev MinIO.    |
| `S3_PUBLIC_ENDPOINT`   | No                    | `S3_ENDPOINT`           | Storage URL as reached **by your devices**, used only to sign presigned URLs. Required when `S3_ENDPOINT` is private, like a Docker service name.   |
| `S3_REGION`            | No                    | `us-east-1`             | Storage region. Use `auto` for Cloudflare R2.                                                                                                       |
| `S3_ACCESS_KEY_ID`     | No                    | `minioadmin`            | Access key.                                                                                                                                         |
| `S3_SECRET_ACCESS_KEY` | No                    | `minioadmin`            | Secret key.                                                                                                                                         |
| `S3_BUCKET`            | No                    | `donut-sync`            | Bucket name. Created automatically if it does not exist.                                                                                            |
| `S3_FORCE_PATH_STYLE`  | No                    | `true`                  | Path-style addressing. Keep `true` for MinIO, R2, and most S3-compatible services. Set to `false` for AWS S3, which uses virtual-hosted addressing. |
| `SYNC_JWT_PUBLIC_KEY`  | No                    | none                    | RS256 public key that enables JWT (multi-tenant cloud) auth. Not needed for self-hosting; use `SYNC_TOKEN` instead.                                 |

## Using external S3 storage

Drop the `minio` service and its `depends_on` block, then point Donut Sync at your provider. Because the server always sends an explicit endpoint, set `S3_ENDPOINT` for every provider, including AWS.

These providers are already on a public hostname that both the server and your devices can reach, so leave `S3_PUBLIC_ENDPOINT` unset. It only exists for the case where storage sits on a private address.

AWS S3 (note the explicit endpoint and `S3_FORCE_PATH_STYLE: "false"`):

```yaml
services:
  donut-sync:
    image: donutbrowser/donut-sync:latest
    ports:
      - "3929:3929"
    environment:
      SYNC_TOKEN: change-me-to-a-long-random-secret
      PORT: "3929"
      S3_ENDPOINT: https://s3.us-east-1.amazonaws.com
      S3_REGION: us-east-1
      S3_ACCESS_KEY_ID: your-aws-access-key
      S3_SECRET_ACCESS_KEY: your-aws-secret-key
      S3_BUCKET: your-bucket-name
      S3_FORCE_PATH_STYLE: "false"
    restart: unless-stopped
```

Cloudflare R2:

```yaml
services:
  donut-sync:
    image: donutbrowser/donut-sync:latest
    ports:
      - "3929:3929"
    environment:
      SYNC_TOKEN: change-me-to-a-long-random-secret
      PORT: "3929"
      S3_ENDPOINT: https://<account-id>.r2.cloudflarestorage.com
      S3_REGION: auto
      S3_ACCESS_KEY_ID: your-r2-access-key
      S3_SECRET_ACCESS_KEY: your-r2-secret-key
      S3_BUCKET: your-bucket-name
      S3_FORCE_PATH_STYLE: "true"
    restart: unless-stopped
```

Other providers (Backblaze B2, DigitalOcean Spaces, Wasabi, and similar) work the same way: set `S3_ENDPOINT` to the provider's endpoint and keep `S3_FORCE_PATH_STYLE: "true"` unless the provider documents virtual-hosted addressing.

## Connect Donut Browser

When you turn on sync for a profile (or open the sync settings), Donut Browser shows the Sync Configuration dialog. Fill in:

1. **Server URL**: `http://your-server:3929`, or your HTTPS reverse-proxy URL in production.
2. **Sync Token**: the exact value you set for `SYNC_TOKEN`.

Donut Browser validates the connection by calling `/readyz` before saving, so the check covers storage as well as the process, and it shows the storage endpoint the server reports underneath the green status. Compare that value against what your device can actually reach. Once it is configured, you can enable sync on individual profiles, proxies, groups, and extensions, and the paid "cloud sync" gate is lifted because you are your own backend.

## Securing your deployment

- **Use a strong token.** Generate one with `openssl rand -hex 32` and keep it secret. The token is compared in constant time on the server.
- **Terminate TLS with a reverse proxy.** The token travels as a bearer header, so it must not cross plain HTTP in production. Put Caddy, Nginx, or Traefik in front of the server.
- **Limit exposure.** On a VPS, bind the container to localhost and only expose it through the reverse proxy, or restrict the port with firewall rules.
- **Scope your storage credentials.** Use dedicated keys that can read and write only the sync bucket.

Caddy makes TLS automatic:

```
sync.yourdomain.com {
    reverse_proxy localhost:3929
}
```

Nginx, terminating TLS yourself:

```nginx
server {
    listen 443 ssl;
    server_name sync.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://localhost:3929;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

With TLS in place, set the **Server URL** in Donut Browser to `https://sync.yourdomain.com`.

### Running on a remote server

Putting the containers on a VPS changes one thing: `localhost` no longer means the same machine as Donut Browser. If you keep the bundled MinIO, your devices need a route to it as well as to the server, and `S3_PUBLIC_ENDPOINT` has to say so.

Using external storage such as AWS or R2 avoids this entirely, because storage is already public. With the bundled MinIO, give it its own hostname:

```
sync.yourdomain.com {
    reverse_proxy localhost:3929
}

storage.yourdomain.com {
    reverse_proxy localhost:9000
}
```

Then point clients at that hostname, and stop publishing MinIO on a raw public port:

```yaml
services:
  donut-sync:
    environment:
      S3_ENDPOINT: http://minio:9000
      S3_PUBLIC_ENDPOINT: https://storage.yourdomain.com
      # ... the rest unchanged

  minio:
    ports:
      # Bound to loopback so only the reverse proxy can reach it.
      - "127.0.0.1:9000:9000"
```

Presigned URLs carry their own short-lived credentials, so the storage hostname does not need separate authentication. Do keep it on HTTPS, since profile data crosses it.

### Troubleshooting

**Sync reports "Critical files failed to upload" and 0 bytes transferred.** Your devices cannot reach the storage endpoint. This is by far the most common self-hosting problem, and it looks like a working setup: the server is healthy, so `/health`, `/readyz`, and the in-app connection test all pass, because none of them travel the path the files take.

Check what the server hands out, then try it from the device that is failing:

```bash
curl https://sync.yourdomain.com/readyz
# {"status":"ready","s3":true,"storageEndpoint":"http://minio:9000"}   <- unreachable
```

A `storageEndpoint` like `http://minio:9000` or `http://localhost:9000` seen from a different machine is the bug. Set `S3_PUBLIC_ENDPOINT` to an address that device can open, publish or proxy the storage port, and restart. The error text in Donut Browser names the underlying cause after `Cause:`, which tells you whether it was DNS, a refused connection, or a rejected request.

**`/readyz` returns 503 with `"s3":false`.** The server cannot reach storage itself. This one is about `S3_ENDPOINT`, not `S3_PUBLIC_ENDPOINT`: check the credentials, the bucket, and that the endpoint resolves from inside the container.

**The connection test passes but nothing ever syncs.** Confirm sync is actually enabled on the individual profile. If it is, treat it as the first case above and verify `storageEndpoint` from the failing device.