Skip to content

Cloud and teams

Run your own sync server.

On this page

Donut Sync is the small server behind sync: profiles with their browser data, proxies, VPNs, groups and extensions. Sync through Donut’s cloud is a paid feature. A server you run yourself is free, and the data stays in storage you control.

The server is one container that talks to any S3-compatible bucket. The quick start runs a small S3 server next to it, so everything stays on one machine. For production, point it at Cloudflare R2, Backblaze B2, AWS S3 or any other S3 API.

What you need

  • Docker with Docker Compose (docker compose, included in modern Docker Desktop / Engine).
  • An S3-compatible bucket. The quick start below runs one for you.

Quick start

Create a docker-compose.yml. It runs Donut Sync and a local S3 server (rclone), so everything stays on one machine.

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 storage, over the compose network.
      S3_ENDPOINT: http://s3:9000
      # How Donut Browser reaches storage. This must be an address your devices
      # can open. `s3:9000` only resolves inside the compose network, so
      # without this every file transfer fails. See "Running on a remote server".
      S3_PUBLIC_ENDPOINT: http://localhost:9000
      S3_REGION: us-east-1
      S3_ACCESS_KEY_ID: change-me-access-key
      S3_SECRET_ACCESS_KEY: change-me-secret-key
      S3_BUCKET: donut-sync
      S3_FORCE_PATH_STYLE: "true"
    depends_on:
      s3:
        condition: service_healthy
    restart: unless-stopped

  s3:
    image: rclone/rclone:latest
    command:
      - serve
      - s3
      - /data
      - --addr
      - :9000
      # Must match S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY above.
      - --auth-key
      - change-me-access-key,change-me-secret-key
      - --force-path-style
      - --vfs-cache-mode
      - writes
    ports:
      # Donut Browser uploads and downloads profile files straight from
      # storage, so this port has to be reachable from your devices.
      - "9000:9000"
    volumes:
      - s3_data:/data
    healthcheck:
      test: ["CMD", "nc", "-z", "127.0.0.1", "9000"]
      interval: 2s
      timeout: 5s
      retries: 15
    restart: unless-stopped

volumes:
  s3_data:

Start it:

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

# 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

VariableRequiredDefaultDescription
SYNC_TOKENThis, SYNC_PUBLIC_URL, or bothnoneBearer token clients send in the Authorization header while signed out. Anyone who has it can read and change the whole bucket. Use a long random value.
SYNC_PUBLIC_URLThis, SYNC_TOKEN, or bothnoneThe exact address people enter in Donut Browser. Lets paid Donut accounts sign in (see below).
DONUT_API_URLNohttps://api.donutbrowser.comWhere the server checks Donut account sign-ins.
PORTNo3929Port the server listens on.
S3_ENDPOINTYesnoneS3-compatible endpoint URL, as reached by the server. The server does not start without it.
S3_PUBLIC_ENDPOINTNoS3_ENDPOINTStorage URL as reached by your devices, used only to sign presigned URLs. Required when S3_ENDPOINT is private, like a Docker service name.
S3_REGIONNous-east-1Storage region. Use auto for Cloudflare R2.
S3_ACCESS_KEY_IDYesnoneAccess key.
S3_SECRET_ACCESS_KEYYesnoneSecret key.
S3_BUCKETYesnoneBucket name. Created automatically if it does not exist.
S3_FORCE_PATH_STYLENotruePath-style addressing. Keep true for R2, rclone and most S3-compatible services. Set to false for AWS S3, which uses virtual-hosted addressing.
SYNC_JWT_PUBLIC_KEYNononeRS256 public key that enables JWT (multi-tenant cloud) auth. Not needed for self-hosting; use SYNC_TOKEN instead.

Using external S3 storage

Drop the s3 service and the 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"):

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:

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

In Donut Browser, open Account → Self-hosted while signed out. 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.

Click Test connection, then save. The test calls /readyz, so it checks storage as well as the server, and shows the storage endpoint the server reports. Compare that value with what your device can reach. Then turn on sync for profiles, proxies, groups and extensions. No plan is needed, and there is no profile limit.

Account page, Self-hosted tab. A Server URL field holds https://sync.example.net for your own donut-sync server, with Test connection and Save buttons and a note that all team members must use the same server.
Point sync at your own donut-sync server.

Sign in with a paid Donut account

A paid account can sync with your server while it stays signed in, so it keeps automation, MCP and the other plan features. Set SYNC_PUBLIC_URL to the address people enter, for example https://sync.example.com. Then, in Donut Browser, sign in and open Account → Your own sync server, enter that address, select Test connection, then save.

  • The app sends a short token that the Donut API issues for that one address. Your server asks the Donut API whose it is, so it must reach https://api.donutbrowser.com. No shared secret is involved.
  • Each account gets its own space in the bucket. A team uses its owner’s space, with the team’s roles and group access.
  • Your plan’s profile limit applies, as on Donut cloud.
  • Remote sessions and Cookie Bot run on Donut’s hosts, which get profiles from Donut cloud. They are not available while you sync with your own server.
  • All members of a team must use the same server.

Securing your deployment

  • Use a strong token. Generate one with openssl rand -hex 32 and keep it secret. The server compares it in constant time.
  • Put a reverse proxy with TLS, such as Caddy, Nginx or Traefik, in front of the server. The token travels as a bearer header, so it must not cross plain HTTP in production.
  • On a VPS, bind the container to localhost and expose it only through the reverse proxy, or restrict the port with firewall rules.
  • Give the server storage 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:

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 S3 server, 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 S3 server, 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 the S3 port to the internet:

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

  s3:
    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:

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

A storageEndpoint like http://s3: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 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.

Checked against the app on .