docker compose status of the firefox-sync stack: healthy containers, heartbeat 200, tokenserver services table

Self-hosted Firefox Sync: fixing 503 “unable to get a node” on fresh installs

Self-hosting Firefox Sync is a great way to keep bookmarks, passwords, tabs and history off Mozilla’s servers — but every fresh deployment of the popular docker-compose kit was failing logins with a 503 “Unexpected error: unable to get a node”. Containers healthy, database seeded, yet not a single account could ever connect. This post walks through the root cause, the fix, and proof from my production server.

This post was written with AI assistance (opencode) and reviewed before publishing.

The symptom

You deploy the stack, all containers come up healthy, the init container exits cleanly — and then Firefox refuses to sync:

Unexpected error: unable to get a node

No amount of wiping volumes and re-deploying helps. The database looks fine, the migrations run, the node table is populated. And yet: every login, 503.

The root cause

The tokenserver resolves which node should serve a user by looking up the sync-1.5 service in its database, then matching the nodes row against that service id.

The catch: on every fresh database, the upstream migrations guarantee that sync-1.5 lands at id 3 — the 2021 migration seeds ids 1–2, a later migration clears the table, and the 2026-02-26 migration re-inserts sync-1.5 without resetting the auto-increment counter.

The init script (db_init.sh), however, hardcoded 1:

-- BEFORE: hardcoded — never matches the real services id
INSERT IGNORE INTO services (id, service, pattern)
  VALUES ('1', 'sync-1.5', '{node}/1.5/{uid}');
INSERT INTO nodes (id, service, node, available, current_load, capacity, downed, backoff)
  VALUES ('1', '1', '${DOMAIN}', '1', '0', '5', '0', '0')
  ON DUPLICATE KEY UPDATE node='${DOMAIN}';

So nodes.service = 1 while services.id = 3. The lookup WHERE service = ? returns nothing, and the tokenserver correctly answers: unable to get a node. It’s a repo bug, not data drift — which is why resetting volumes reproduced it every time.

The fix

Resolve the service id dynamically instead of hardcoding it:

-- AFTER: resolved dynamically — works wherever sync-1.5 lands
INSERT INTO services (service, pattern)
  SELECT 'sync-1.5', '{node}/1.5/{uid}' FROM DUAL
  WHERE NOT EXISTS (SELECT 1 FROM services WHERE service = 'sync-1.5');
INSERT INTO nodes (id, service, node, available, current_load, capacity, downed, backoff)
  VALUES ('1', (SELECT id FROM services WHERE service = 'sync-1.5'), '${DOMAIN}',
          '1', '0', '5', '0', '0')
  ON DUPLICATE KEY UPDATE node='${DOMAIN}',
    service=(SELECT id FROM services WHERE service = 'sync-1.5');

The fix is upstreamed as porelli/firefox-sync#23 and shipped in the fork’s init image. While I was at it, the image workflow also learned the renamed SYNCSTORAGE_DATABASE_BACKEND / TOKENSERVER_DATABASE_BACKEND build-args for syncstorage-rs 0.23+ (porelli/firefox-sync#24) — without it, 0.23 images silently build for Google Spanner and panic on MySQL URLs.

Running in production

Here’s the stack on my server (real output), including the Exited (0) init container, the /__heartbeat__ health probe, and the tokenserver database showing exactly why the hardcoded id could never match:

firefox-sync production: compose ps, heartbeat 200, services table

Proof: a real Firefox client syncing

And the payoff — the client-side success log from my Firefox instance, syncing every few minutes against my server:

firefox weave success log excerpt

POST success 200 against firefox-sync.example.com, engines uploading, Sync completed … after 0.14 secs.

Deploy your own

The MySQL-backend images are published weekly as ghcr.io/tmiland/firefox-sync:

  1. Clone tmiland/firefox-sync and run ./prepare_environment.sh to generate your .env and config templates
  2. docker compose up -d (put a reverse proxy in front — nginx example included)
  3. Health check: curl https://your-domain/__heartbeat__
  4. Point Firefox at your server: about:config → identity.sync.tokenserver.uri → https://your-domain/1.0/sync/1.5 (desktop; Android and iOS instructions in the README)
  5. Optional: cap who can use your server with MAX_USERS — implemented as a SQL trigger workaround, since syncstorage-rs has no native allowlisting

Credits & disclaimer

This is a fork of porelli/firefox-sync — all credit for the excellent deployment kit goes upstream; the fixes above are proposed back as #23 and #24. As upstream warns: the project is under development, not endorsed by Mozilla, and you shouldn’t rely on it as the sole store for your bookmarks and passwords.

Share and Enjoy !

Shares

Comments (via GitHub)