Keeps OpenTubeX data in sync across devices: subscriptions, playlists, watch history, profiles, sessions, and settings.
Based on the LibreTube sync server.
OpenTubeX clients encrypt everything they sync with a separate privacy passphrase before uploading it. The server stores each collection as an opaque, revisioned document.
| The server can see | The server cannot see |
|---|---|
| Account activity and request timing | Synced data, such as subscriptions and history |
| Client IP addresses | Device names and platforms |
| Collection names and approximate size (padded to 64 KiB blocks) | The privacy passphrase or key |
Older clients can still use the LibreTube-compatible plaintext endpoints. Once an account uploads its first encrypted collection:
- the server deletes that account's plaintext sync records (existing server backups keep them)
- plaintext sync endpoints reject the account, so an older client cannot re-upload readable data
The public instance's privacy policy is in PRIVACY.md.
-
Download docker-compose.yml (SQLite) or docker-compose.postgres.yml (PostgreSQL). Both images support ARM64 and x86:
Image Database ghcr.io/opentubex/sync-server:latest-sqliteSQLite ghcr.io/opentubex/sync-server:latest-postgresPostgreSQL -
Create a
.envfile next to it. Use the same value for both secrets (see Secrets):secret=$(openssl rand -hex 32) printf 'SECRET_KEY=%s\nUSERNAME_SECRET=%s\n' "$secret" "$secret" > .env # PostgreSQL only: echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
-
SQLite only: let the container user (uid
10001) write the data directory (see File permissions):mkdir -p ./data && sudo chown -R 10001:10001 ./data -
Start the server with
docker compose up -d. It listens on127.0.0.1:8080. -
Point a reverse proxy at it. See Reverse proxy.
Set options in a config.toml in the server's working directory, or as
environment variables (case-insensitive, e.g. ALLOW_REGISTRATION=false).
Environment variables override config.toml. For options inside a section, join
the names with __, e.g. OIDC__CLIENT_ID for client_id in [oidc].
config.dev.toml is an annotated example.
| Option | Description | Default |
|---|---|---|
database_url |
Database connection string, e.g. ./data/db.sqlite |
required |
secret_key |
Signs authentication tokens. At least 32 bytes | required |
username_secret |
Derives account name hashes. See Secrets | secret_key |
trust_forwarded_for |
Take client addresses from X-Forwarded-For. See Reverse proxy |
false |
trusted_proxy_hops |
Number of proxies in front of the server | 1 |
allow_registration |
Allow new accounts, including first-time OIDC sign-ins | true |
validate_submitted_metadata |
Check submitted channel and video metadata against YouTube | true |
privacy_policy_url |
HTTP(S) URL without credentials, shown to clients through /health |
none |
migration_approval |
Pending migration versions to apply. See Database migrations | none |
oidc |
OpenID Connect login. See OIDC | disabled |
- The server refuses to start if
secret_keyis missing, shorter than 32 bytes, or a placeholder such aschangeme. - Accounts are looked up by
HMAC(username). Changing the secret behind that hash locks every account out. - Set
username_secretto the initialsecret_keyvalue. You can then rotatesecret_keylater, for example after a leak, without locking anyone out. Never changeusername_secret.
Registration and login are rate limited per client address. Behind a reverse
proxy, every request comes from the proxy, so all clients would share one
limit. Configure the server to read the real address from X-Forwarded-For:
| Setup | trust_forwarded_for |
trusted_proxy_hops |
|---|---|---|
| Server directly reachable, no proxy | false |
β |
| One reverse proxy (example compose files) | true |
1 |
Cloudflare Tunnel (cloudflared) |
true |
2 |
| Other proxy chains | true |
number of proxies |
- The server counts
trusted_proxy_hopsentries from the end of the header. Your own proxies appended those, so clients cannot pick their address by sending the header themselves. - Only enable
trust_forwarded_forwhen the server cannot be reached directly. Otherwise clients can spoof their address. - A wrong hop count is worse than none. With Cloudflare Tunnel at
1, the address changes between Cloudflare edge servers. Clients can then bypass the limit, and others get rate limited at random. - To check your setup, send more than 10 login requests within a minute from
one client. Responses should switch to
429and stay there. If they alternate, the hop count is wrong. - Access logs use the same client address. They fall back to the peer address when the header is untrusted, missing, or invalid.
- The limiter is per process and best effort. Rate limit at the proxy as well, which is required with multiple replicas.
- Every
/v1/...path also works without the/v1prefix. Proxy or WAF rules for/v1/account/deletemust also cover/account/delete.
The image runs as uid 10001, but a bind-mounted directory keeps its host
ownership. SQLite deployments need a data directory that uid can write:
mkdir -p ./data && sudo chown -R 10001:10001 ./data-
Otherwise the server fails with a database connection timeout, not a permission error.
-
Upgrading from an image that ran as root needs the same command. It only changes ownership, not data.
-
Without
sudo, run the container as the directory's owner instead:services: sync: user: "1000:1000" # from `id -u`:`id -g`
The server never migrates an existing database on its own. To upgrade:
- Back up the database and verify the backup.
- Start the new version. It refuses to start and logs the pending migration versions.
- Set
migration_approvalto exactly those versions, comma-separated (for exampleMIGRATION_APPROVAL=202607211800000000), and start it again. SQLite also writes a*.pre-migration-<version>backup before migrating. - Remove
migration_approvalso it cannot approve a later migration.
Add an [oidc] section to config.toml, or set the matching OIDC__*
environment variables, to let users sign in through an OpenID Connect provider.
All options except allowed_redirect_urls are required. As an environment
variable, allowed_redirect_urls takes a comma-separated list, e.g.
OIDC__ALLOWED_REDIRECT_URLS=myapp://oidc,https://app.example.com/oidc.
With the Compose quick start, add the variables to the service's environment
list. Compose doesn't pass entries in .env to the container on its own.
| Option | Description | Example |
|---|---|---|
provider_url |
Base URL of the OIDC provider | https://auth.example.com |
client_id |
Client ID of the OAuth app | |
client_secret |
Client secret of the OAuth app | |
app_url |
Public URL of this server | https://sync.example.com |
allowed_redirect_urls |
Client URLs that login and deletion may redirect back to | ["myapp://oidc"] |
- Allow these redirect URLs in the OAuth app:
<app_url>/v1/account/oidc/authenticate/callback<app_url>/v1/account/oidc/delete/callback
- OIDC login hands the session token to the client's redirect URL. The server
only accepts URLs listed exactly in
allowed_redirect_urls, and refuses OIDC login and deletion while the list is empty. - With
allow_registration = false, users cannot sign in with OIDC for the first time.
- YouTube, to validate submitted channel and video metadata. Disable with
validate_submitted_metadata = false. - Have I Been Pwned, to reject breached passwords at registration and password changes. Only the first five hex digits of the password's SHA-1 hash are sent. If the lookup fails, the password is accepted.
Every instance serves interactive API documentation at /docs. It lists the
canonical /v1 paths. Each one also works without the prefix, using the same
handler, and unprefixed paths remain version 1 aliases if a later version is
added.
Register or log in with a username and password, or through OIDC. The
response contains a jwt. Send it as a header or cookie on authenticated
requests:
Authorization: <jwt>
Cookie: Authorization=<jwt>To delete an OIDC account, send an authenticated POST /v1/account/oidc/delete
and open the returned authorization_url in a browser. The account is deleted
once its owner signs in there.
GET /health returns the server status, its capabilities, and
privacy_policy_url if configured. Clients use the capabilities to decide which
features they can use.
| Capability | Meaning |
|---|---|
encrypted_sync |
Encrypted sync is available |
collection_changes |
Collections accept incremental changes |
live_sync |
Change notifications and device requests |
account_sessions |
Tokens are bound to account sessions |
key_pairing |
Secure device pairing |
bulk_sync |
Plaintext subscriptions and watch history accept bulk uploads |
history_page_size |
Maximum page size for plaintext watch history |
seen_videos, seen_posts, seen_rss_items, watch_stats, live_reminders, external_media_positions |
The matching collection exists |
| Endpoint | Purpose |
|---|---|
GET /v1/encrypted_sync |
Manifest of collections and revisions |
GET /v1/encrypted_sync/{collection} |
Download one collection |
PUT /v1/encrypted_sync/{collection} |
Upload one collection |
- Each collection has its own revision. Uploading with a stale revision fails
with
409without blocking other collections. - While an account still has plaintext records, the manifest sets
legacy_data. The client then pulls and merges them before its first encrypted upload. - Each plaintext domain is deleted in the same transaction that stores its encrypted collection, so an interrupted migration can resume.
| Collection | Size limit | Capability |
|---|---|---|
settings |
2 MiB | |
profiles, sessions, sessionsV2 |
8 MiB | |
watchStats (per-device totals) |
8 MiB | watch_stats |
liveReminders |
8 MiB | live_reminders |
subscriptions, playlistBookmarks |
16 MiB | |
seenVideos |
16 MiB | seen_videos |
seenPosts |
16 MiB | seen_posts |
seenRssItems |
16 MiB | seen_rss_items |
externalMediaPositions (media opened by URL) |
16 MiB | external_media_positions |
playlists, history |
64 MiB |
All collections of an account, including pending changes, are limited to
128 MiB in total. seenPosts is separate from seenVideos so older clients
that sync seenVideos cannot erase post marks.
With collection_changes, clients can upload a small encrypted change instead
of the whole collection. The server stores changes as opaque ciphertext, so
their format is up to the clients.
POST /v1/encrypted_sync/{collection}/changestakes the same body asPUTand appends one change at the next revision. Each change can be up to 256 KiB.GET /v1/encrypted_sync/{collection}?since={revision}returnsbase_revisionandchanges:- If the change log covers
since, the response omitspayloadand lists only later changes. - Otherwise it contains the full
payloadatbase_revision, followed by every change after it.
- If the change log covers
PUTstores a full payload and clears the change log.- While a collection has pending changes, reads without
sincefail with409, because those clients cannot apply changes.
An append fails with 412, and the client uploads the full collection instead,
when:
- any session of the account that was active in the last 30 days has never
read with
sinceor appended a change. This includes an older client signing in, or using a legacy token for the first time. - the collection has never been fully uploaded
- the collection already has 64 pending changes
- the pending changes would outgrow the full payload (minimum 1 MiB)
With live_sync:
GET /v1/encrypted_sync/changes?since={cursor}returns the current cursor once it differs fromsince, waiting up to 25 seconds for a change.GET /v1/encrypted_sync/events?since={id}lists encrypted events for the requesting device: activity records that clients attach to uploads, and requests from other devices, such as opening a video.POST /v1/encrypted_sync/eventssends an encrypted request to another device, andDELETE /v1/encrypted_sync/events/{id}acknowledges one.- Event payloads are limited to 256 KiB, and an account can have up to 100 pending device requests.
With account_sessions, each token belongs to a stored session, named in its
jti claim. Revoked or expired sessions stop authenticating immediately.
| Endpoint | Purpose |
|---|---|
GET /v1/account/sessions |
List active sessions, and whether the account supports password login |
PATCH /v1/account/sessions/{id} |
Store encrypted device information (current for the requesting session) |
DELETE /v1/account/sessions/{id} |
Revoke a session |
PUT /v1/account/password |
Change the password and revoke all other sessions |
- Creation. Registration, password login, and OIDC login create an active session. Pairing creates a provisional one that becomes active once the new device consumes the pairing payload.
- Devices. Clients send a random 16-byte base64url device ID when they authenticate. Older clients that omit it get a server-assigned one.
- Device information. The device name, operating system, release, and architecture are encrypted with the privacy key. Any active device can replace them, for example to rename another device.
- Timestamps. The server keeps creation, last-active, and expiry times. Last-active is written at most every five minutes. Expired and revoked sessions are removed within about an hour of expiring.
- Password changes verify the current password, return a new token for the requesting device, and revoke every other session in one transaction. A login that checked the old password concurrently is rejected too.
Tokens issued before account sessions have no jti:
- Accounts that existed at the migration can keep using such a token until it expires. Its session is created on first use. New accounts need session-bound tokens.
- The session and device IDs are derived from a SHA-256 digest of the token. The token itself is not stored.
- A revoked legacy session leaves a tombstone until the token expires, so the token cannot recreate it.
- Password changes reject legacy tokens that have not created a session yet.
With key_pairing, a signed-in device can set up a new device for encrypted
sync without the password or passphrase:
- The new device creates a pairing session and shows a QR or text code.
- A signed-in device scans it and claims the session. The server creates a provisional account session and a token for the new device.
- The signed-in device encrypts the token, account name, privacy key, privacy salt, and a six-digit verification code, and uploads them.
- The new device consumes the payload. This deletes it and activates the session.
| Endpoint | Authentication | Purpose |
|---|---|---|
POST /v1/pairing |
none | Create a session with a recipient-token hash |
GET /v1/pairing/{id} |
recipient token | Read its metadata and state |
POST /v1/pairing/{id}/claim |
account | Bind it to the account and create the provisional session |
PUT /v1/pairing/{id} |
account | Approve it with the encrypted payload |
POST /v1/pairing/{id}/consume |
recipient token | Return and delete the payload |
DELETE /v1/pairing/{id} |
recipient token | Cancel it |
What the server knows. It stores the session ID, a SHA-256 hash of the recipient token, the recipient public key, device IDs, the new device's display name, the expiry, the account ID after the claim, and the encrypted payload. It never receives the QR-only secret, the recipient private key, the privacy key, or the passphrase. It knows the new token because it created it. It can drop or overwrite the payload, but cannot decrypt it or forge a valid one.
Limits.
- Sessions expire after two minutes and are usually deleted within another 30 seconds. Cancelling deletes them right away.
- At most 10,000 active sessions server-wide, and five claimed sessions per account.
- Authenticated pairing requests are limited to 120 per account per minute. Creation uses the per-address rate limit.
- Repeating a successful claim or approval with the same data succeeds again.
The protocol, threat model, serialization, and test vector are documented in
the OpenTubeX client repository at docs/sync-key-pairing-v1.md.
- Copy
config.dev.tomltoconfig.toml. - Set
secret_keyto the output ofopenssl rand -hex 32. - Run
cargo run. - Open http://localhost:8080/docs for the API playground.
- Generate a migration for each backend:
MIGRATION_DIRECTORY=migrations/<backend> diesel migration generate <name>. - Edit
up.sqlanddown.sqlinmigrations/<backend>/..._<name>. - Add the matching Rust structs to
src/models.rs.
See the Diesel guide for details.