self-hosted-firefox-sync¶
Modules
Run your own Firefox Sync server (syncstorage-rs)
as a single self-contained container — the sync server and its MariaDB
backend bundled into one hand-built OCI image, fronted by nginx + ACME.
Why not services.firefox-syncserver?¶
NixOS ships a services.firefox-syncserver module, but it wires syncserver to
a host MySQL/MariaDB via the NixOS database modules. That drags in a
system-wide database just for one small service. This recipe takes the opposite
approach: syncserver and a private MariaDB live together inside one image with a
custom entrypoint, so the whole stack is a single container with two bind-mounted
directories and nothing else on the host. The cost is a bespoke init script.
The entrypoint pattern (boot-DB-then-exec)¶
The image has no init system, so the entrypoint does the sequencing by hand:
mkdir/chownthe datadir and socket dir,mysql_install_dbon first boot only (guarded on/var/lib/mysql/mysql),- launch
mariadbdin the background, - poll
mysqladmin pingfor up to 30s until the socket answers, aborting early (kill -0on the recorded pid) if mariadbd dies while we wait, - create the
syncstorageandtokenserverdatabases (idempotent), execsyncserver so it becomes PID 1 and gets signals/reaping right.
The exec on the last line matters: without it, syncserver runs as a child of
the shell and container stop signals go to the wrong process. For the same
reason the script never waits on the backgrounded mariadbd — it is supposed
to outlive the shell, as a child of the exec'd PID 1.
The three traps¶
1. uid/gid must agree in three places¶
This is the one that silently breaks a bind-mounted database. The mysql user's
uid/gid must be identical in all three of:
- the host
firefox-syncuser (users.users.firefox-sync.uid), - the
0700tmpfiles ownership of the data directories, and - the
mysqlline baked into the image's/etc/passwd(shipped viawriteTextDir, because the minimal image has no user database of its own).
The host owns the datadir at that numeric uid; inside the container mariadbd
runs as mysql, which only maps to the same files if the image's /etc/passwd
resolves mysql to that same number. Get them out of sync and MariaDB fails to
read a datadir it appears to own. The actual number is arbitrary — the module
exposes it as one uid/gid option that feeds all three spots.
2. syncserver has no port flag — 8000 is hardcoded¶
syncserver accepts only --config (a TOML file); there is no --port flag or
port env var. With no config it binds its compiled-in default of 8000. So the
port option here is really "the port syncserver already listens on," and it must
match the nginx proxyPass. If you truly need a different port you must also
mount a config file setting port = … — changing the module option alone will
just make nginx proxy to a dead port.
3. --network=host¶
The container uses host networking so syncserver binds 8000 directly on the host
loopback and nginx proxies to 127.0.0.1:8000. Keep this in mind if you run
multiple host-network containers — the ports share the host namespace.
Usage¶
{
imports = [ ./modules/self-hosted-firefox-sync ];
modules.services.firefox-sync = {
enable = true;
domain = "example.com"; # served at ffsync.example.com
acmeHost = "example.com"; # an existing security.acme cert
secretsFile = "/run/secrets/firefox-sync.env";
};
}
Then point Firefox at your server: in about:config set
identity.sync.tokenserver.uri to
https://ffsync.example.com/1.0/sync/1.5 and re-log-in to your Firefox Account.
The secret¶
secretsFile is an environment file supplying SYNC_MASTER_SECRET:
Generate one with head -c 32 /dev/urandom | base64. Any secrets mechanism works
(agenix, sops-nix, or a plain root-only file) — the module just needs a readable
path. Keep it stable: rotating it invalidates existing sync data.
Options¶
| Option | Default | Notes |
|---|---|---|
enable |
false |
Turn the service on. |
domain |
null |
Base domain; served at ffsync.<domain>. Required. |
acmeHost |
null |
security.acme cert name for the vhost. Required. |
port |
8000 |
Must match syncserver's hardcoded bind port (see trap 2). |
dataDir |
/var/lib/firefox-sync |
Bind-mounted at /data. |
mariadbDataDir |
/var/lib/firefox-sync/mariadb |
Bind-mounted at /var/lib/mysql. |
uid / gid |
990 |
Load-bearing in three places (trap 1). Any free id. |
secretsFile |
— | Env file with SYNC_MASTER_SECRET. Required. |
extraPodmanOptions |
[] |
Extra podman flags, e.g. [ "--runtime=runsc" ] for gVisor. |
Hardened runtime (optional)¶
The module defaults to the standard runc/crun OCI runtime. If you have a
sandboxing runtime such as gVisor registered with podman,
opt in with:
Security notes¶
- Bundled MariaDB root has no password. The entrypoint leaves the
rootaccount onunix_socketauth (plus an empty password), and syncserver talks to the DB as root. mariadbd is started with--bind-address=127.0.0.1, so the database is reachable only on host loopback — but under--network=hostthat loopback is the host's, so any local process on the host can reach127.0.0.1:3306, and you must not open port 3306 in the host firewall. The DB holds only your own sync blobs (encrypted client-side by Firefox), but treat the host as the trust boundary. - The entrypoint is world-readable. It is emitted via
writeShellScript, so it lives in/nix/storereadable by every local user. Keep real secrets out of it; the only sensitive value (SYNC_MASTER_SECRET) is passed separately viasecretsFileand never baked into the image.
Caveats¶
- Single instance. MariaDB lives inside the container with its datadir on a
host bind-mount; run exactly one instance against a given
mariadbDataDir. - Backups. Everything durable is under
dataDir/mariadbDataDir. Back those up (or snapshot them) — the container itself is disposable. - First boot is slower.
mysql_install_dbruns once on an empty datadir; give the first start extra time before the ping loop’s 30s budget matters.
Source¶
modules/self-hosted-firefox-sync/default.nix
# self-hosted-firefox-sync
#
# Run your own Firefox Sync (syncstorage-rs) bundled with its MariaDB backend
# in a single, hand-built OCI image. This deliberately avoids NixOS's
# `services.firefox-syncserver`; instead the container's entrypoint boots
# mariadbd, waits for it, creates the databases, then execs syncserver.
#
# Import this module, set `enable`, `domain`, `acmeHost`, and `secretsFile`.
#
# See README.md for the boot-DB-then-exec entrypoint pattern and the three
# traps this encodes (uid/gid agreement, the hardcoded 8000 port, and host
# networking).
{
config,
pkgs,
lib,
...
}:
with lib;
let
cfg = config.modules.services.firefox-sync;
# Entrypoint: bring MariaDB up in the background, wait for it, create the
# syncstorage/tokenserver databases, then hand the container's PID 1 to
# syncserver via exec.
entrypoint = pkgs.writeShellScript "firefox-sync-entrypoint" ''
set -e
mkdir -p /var/lib/mysql /run/mysqld
chown -R mysql:mysql /var/lib/mysql /run/mysqld
if [ ! -d /var/lib/mysql/mysql ]; then
${pkgs.mariadb}/bin/mysql_install_db --user=mysql --datadir=/var/lib/mysql
fi
# Bind to loopback only. Under `--network=host` the container shares the
# host net namespace, so 0.0.0.0 would put 3306 on every routable
# interface; syncserver (also host-networked) reaches it on 127.0.0.1.
${pkgs.mariadb}/bin/mariadbd --user=mysql --datadir=/var/lib/mysql --bind-address=127.0.0.1 &
mariadb_pid=$!
# Never `wait` here: the script ends in `exec syncserver`, so mariadbd is
# meant to keep running as a child of PID 1, not to be reaped. The pid is
# only used to abort early if mariadbd dies during the readiness wait,
# instead of spinning the full 30s and failing on the first SQL statement.
for i in $(seq 1 30); do
if ${pkgs.mariadb}/bin/mysqladmin ping --silent 2>/dev/null; then
break
fi
if ! kill -0 "$mariadb_pid" 2>/dev/null; then
echo "mariadbd exited before becoming ready" >&2
exit 1
fi
sleep 1
done
${pkgs.mariadb}/bin/mysql -u root <<'SQL'
ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD(''');
CREATE DATABASE IF NOT EXISTS syncstorage;
CREATE DATABASE IF NOT EXISTS tokenserver;
FLUSH PRIVILEGES;
SQL
exec ${pkgs.syncstorage-rs}/bin/syncserver
'';
# The image has no real user database, so we ship one. The `mysql` uid/gid
# here MUST equal cfg.uid/cfg.gid (see below) or mariadbd inside the
# container cannot read the bind-mounted, host-owned datadir.
passwdFile = pkgs.writeTextDir "etc/passwd" ''
root:x:0:0:root:/root:/bin/bash
mysql:x:${toString cfg.uid}:${toString cfg.gid}:MariaDB:/var/lib/mysql:/bin/false
'';
groupFile = pkgs.writeTextDir "etc/group" ''
root:x:0:
mysql:x:${toString cfg.gid}:
'';
firefoxSyncImage = pkgs.dockerTools.buildLayeredImage {
name = "firefox-sync";
tag = "latest";
contents = with pkgs; [
syncstorage-rs
mariadb
bash
coreutils
gnugrep
cacert
passwdFile
groupFile
];
config = {
Cmd = [ "${entrypoint}" ];
Env = [
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
};
};
in
{
options.modules.services.firefox-sync = {
enable = mkEnableOption "self-hosted Firefox Sync server (syncstorage-rs)";
domain = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
description = ''
Base domain. The sync server is served at `ffsync.<domain>`.
Point your browser's `identity.sync.tokenserver.uri` at
`https://ffsync.<domain>/1.0/sync/1.5`.
'';
};
acmeHost = mkOption {
type = types.nullOr types.str;
default = null;
example = "example.com";
description = ''
`security.acme` certificate name to use for the nginx vhost
(`services.nginx.virtualHosts.<name>.useACMEHost`). You are
responsible for provisioning that certificate elsewhere.
'';
};
port = mkOption {
type = types.port;
default = 8000;
description = ''
Port nginx proxies to. Must match what syncserver actually
listens on. syncserver only accepts `--config` (no port
flag/env), so changing this requires also passing a config
file with the matching `port = …`. The 8000 default is
syncstorage-rs's hardcoded default, which is what the
upstream binary binds to with no config.
'';
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/firefox-sync";
description = "Host directory bind-mounted into the container at /data.";
};
mariadbDataDir = mkOption {
type = types.str;
default = "/var/lib/firefox-sync/mariadb";
description = "Host directory for the MariaDB datadir (bind-mounted at /var/lib/mysql).";
};
uid = mkOption {
type = types.int;
default = 990;
description = ''
User ID for the host `firefox-sync` user. This value is
load-bearing in THREE places that must agree, or MariaDB
cannot read its bind-mounted datadir: the host user, the
0700 tmpfiles ownership of the data dirs, and the `mysql`
entry baked into the image's /etc/passwd. The specific number
is arbitrary — pick any free uid — but keep it consistent.
'';
};
gid = mkOption {
type = types.int;
default = 990;
description = "Group ID for the host `firefox-sync` group. See `uid`.";
};
secretsFile = mkOption {
type = types.path;
description = ''
Path to an environment file supplying `SYNC_MASTER_SECRET`
(a long random string; generate with e.g.
`head -c 32 /dev/urandom | base64`). Passed to the container
via podman `environmentFiles`. Any secret-management scheme
works (agenix, sops-nix, a plain root-only file); the module
only needs a readable path at activation time.
'';
};
extraPodmanOptions = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "--runtime=runsc" ];
description = ''
Extra flags appended to the podman run invocation. Use this to
opt into a hardened OCI runtime such as gVisor
(`--runtime=runsc`) if you have one registered on the host.
The module defaults to the standard runc/crun runtime.
'';
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.domain != null;
message = "modules.services.firefox-sync: domain must be set when firefox-sync is enabled";
}
{
assertion = cfg.acmeHost != null;
message = "modules.services.firefox-sync: acmeHost must be set when firefox-sync is enabled";
}
];
services.nginx.virtualHosts."ffsync.${cfg.domain}" = {
forceSSL = true;
useACMEHost = cfg.acmeHost;
locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}/";
};
users = {
users.firefox-sync = {
uid = cfg.uid;
isSystemUser = true;
group = "firefox-sync";
};
groups.firefox-sync.gid = cfg.gid;
};
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
"d ${cfg.mariadbDataDir} 0700 ${toString cfg.uid} ${toString cfg.gid} - -"
];
systemd.services.podman-firefox-sync.preStart = lib.mkAfter ''
mkdir -p ${cfg.dataDir} ${cfg.mariadbDataDir}
'';
virtualisation.oci-containers.backend = "podman";
virtualisation.oci-containers.containers.firefox-sync = {
imageFile = firefoxSyncImage;
image = "firefox-sync:latest";
environmentFiles = [ cfg.secretsFile ];
extraOptions = [
"--network=host"
] ++ cfg.extraPodmanOptions;
volumes = [
"${cfg.dataDir}:/data"
"${cfg.mariadbDataDir}:/var/lib/mysql"
];
};
};
}