Self-hosted file sync. One Alpine container runs a key-only sshd, every computer runs Mutagen (tested with v0.18.1) against it, phones copy over SFTP, and two more containers keep hardlinked snapshots where no client key can reach them.
Why: The best part of Dropbox is forgetting it exists. Files land on the laptop and the desktop, and you stop thinking about it. I wanted that feeling on hardware I own, without a subscription, an account, or a platform that grows unnecessary features every quarter.
- Server
- Client
- Daemon on boot and alerts
- Backups
- Clients: add, revoke
- Private folders
- SFTP
- Maintenance
- Layout
Needs Docker with Compose and TCP 2222 (or CUBBY_PORT) reachable by the clients. Docker skips ufw.
git clone https://github.com/jannoguer/cubby.git cubby && cd cubby
mkdir -p data/config data/shared data/clients data/backups data/private-backups
sudo chown 1000:1000 data/backups
sudo chown 0:0 data/private-backups
docker compose up --build -d
docker compose logs server | grep 'Host key fingerprint'Optional settings: cp .env.example .env and uncomment what you need.
| Variable | Default | Meaning |
|---|---|---|
CUBBY_BIND_ADDR |
0.0.0.0 |
Address Docker publishes the port on. |
CUBBY_PORT |
2222 |
Port Docker publishes the service on. |
CUBBY_BACKUP_INTERVAL |
3600 |
Seconds between snapshots. |
CUBBY_BACKUP_KEEP |
168 |
Snapshots kept. |
Back up data/config/: it holds the host key.
Install Mutagen:
brew install mutagen-io/mutagen/mutagen # macOS, Linux
scoop install main/mutagen # WindowsBinaries without a package manager: releases.
Make a key:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/cubbyPut ~/.ssh/cubby.pub on the server as data/clients/<device>.pub, then restart the server, see Clients: add, revoke.
Add to ~/.ssh/config:
Host cubby
HostName SERVER_IP
Port 2222
User cubby
IdentityFile ~/.ssh/cubby
IdentitiesOnly yes
ServerAliveInterval 15
ServerAliveCountMax 3
Port is your CUBBY_PORT if you set one. The keepalives drop a dead connection, after a network change or sleep, within 45 seconds, so Mutagen reconnects instead of waiting on it.
Create the session. On the first connect, compare the fingerprint ssh shows with the server log.
mutagen sync create --name=cubby /path/to/local/folder cubby:/sharedDay to day:
mutagen sync list cubby # status
mutagen sync terminate cubby # remove the session, files stay
mutagen daemon stop # any mutagen command starts it againOn Linux with the systemd unit, stop the daemon with systemctl --user stop mutagen.service instead: the unit restarts a stopped daemon.
Deletions propagate within seconds; the backups are the safety net. Conflicts are never resolved by discarding data: edit the side you want to keep.
Windows, macOS:
mutagen daemon registerLinux:
mkdir -p ~/.config/systemd/user
curl -fsSL -o /tmp/mutagen.service https://raw.githubusercontent.com/jannoguer/cubby/main/client/linux/mutagen.service && sed "s|^ExecStart=mutagen|ExecStart=$(command -v mutagen)|" /tmp/mutagen.service > ~/.config/systemd/user/mutagen.service
mutagen daemon stop
systemctl --user enable --now mutagen.service
loginctl enable-linger "$USER"
systemctl --user status mutagen.serviceAlerts: a notification when a session stays disconnected, halted, conflicted or failing for over a minute.
Windows:
New-Item -ItemType Directory -Force $HOME\.local\bin | Out-Null
Invoke-WebRequest https://raw.githubusercontent.com/jannoguer/cubby/main/client/windows/alert.ps1 -OutFile $HOME\.local\bin\cubby-alert.ps1
$action = New-ScheduledTaskAction -Execute conhost.exe -Argument "--headless powershell -NoProfile -ExecutionPolicy Bypass -File `"$HOME\.local\bin\cubby-alert.ps1`""
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 5)
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask cubby-alert -Action $action -Trigger $trigger -Settings $settings -ForceLinux, macOS:
mkdir -p ~/.local/bin && curl -fsSL -o ~/.local/bin/cubby-alert https://raw.githubusercontent.com/jannoguer/cubby/main/client/alert.sh && chmod +x ~/.local/bin/cubby-alert
(crontab -l 2>/dev/null; echo '*/5 * * * * $HOME/.local/bin/cubby-alert') | crontab -macOS asks once to allow notifications from Script Editor.
The backup container snapshots data/shared/ into data/backups/ at start and every CUBBY_BACKUP_INTERVAL in which something changed. Snapshots are hardlinked, so unchanged files cost no space; data/backups/latest points at the newest and the oldest beyond CUBBY_BACKUP_KEEP are removed.
docker compose ps # backup, backup-private show unhealthy after two missed intervals
docker compose logs -f backup
ls data/backups/Restore by unpacking a snapshot into the container as the sync user; the copy syncs to every client. Never copy into data/shared/ as root: a client can plant a symlink there.
sudo tar -C data/backups/2026-09-03T030000Z -cf - some/folder | docker compose exec -T -u 1000:1000 server tar -C /shared -xf -Offsite copy: pull from another machine, rsync -aH server:/path/cubby/data/backups/ backups/.
Add: drop the device's .pub into data/clients/. Revoke: delete it. Then:
docker compose restart server # other clients reconnect on their ownEach data/clients/<name>.pub also gets /private/<name>, readable only by the keys in that file; list several keys, one per line, to share it. Names: a-z, 0-9, _, -, up to 32, not cubby or root. The server log says why it skipped one.
Sync it logged in as the name:
mutagen sync create --name=private /path/to/private/folder laptop@cubby:/private/laptopWarning
Keep the local folder apart from the cubby session's folder, not inside or around it: Mutagen merges them and copies private files into /shared.
Revoking keeps the folder. backup-private snapshots it into data/private-backups/.
Each folder's uid:
(cd data/private && stat -c '%u %n' -- *)
# 2000 victus-laptop-1
# 2001 msi-work-laptop-1
# 2002 xiaomi-phone-1
# ...Restore as that uid, NEVER AS ROOT:
sudo tar -C data/private-backups/2026-09-03T030000Z -cf - laptop/some/folder | docker compose exec -T -u <uid>:1000 server tar -C /private -xf -Pull and push single files or folders without a sync session. A copy, not a sync: a push overwrites the server's file and Mutagen spreads it to every device; overwritten or deleted files come back only from backups. The server has no rsync, only OpenSSH.
On a computer, use the key and cubby alias from Client:
scp cubby:/shared/some/file . # pull
scp -r folder cubby:/shared/some/ # push
sftp cubby # interactive; cd /shared
scp file laptop@cubby:/private/laptop/ # private folder, logged in as its nameAndroid and iOS work the same way through any SFTP app with key login: server SERVER_IP, port 2222 (or CUBBY_PORT). For the shared folder, set the user to cubby and the path to /shared. For a private folder, set the user to the folder's name instead, and the path to /private/<name>. On first connect, compare the fingerprint the app shows with the server log.
Without ssh-keygen, make an Ed25519 key pair in the browser at sshkeygen.dev, or maxotto-agent.github.io/sshkeygen if it is down. Import the private key into the app and register the .pub as in Clients: add, revoke.
Update, also monthly for Alpine and OpenSSH security fixes: a plain --build reuses the cached apk add layer and never gets them.
git pull
docker compose build --pull --no-cache
docker compose up -dRotate the host key:
docker compose down
sudo rm -rf data/config/ssh_host_keys
docker compose up -d
docker compose logs server | grep 'Host key fingerprint'Then on every client: ssh-keygen -R '[SERVER_IP]:2222', with your CUBBY_PORT if you set one.
server/ sshd image; server/entrypoint.sh builds users and authorized_keys at start
backup/ snapshot image; backup/entrypoint.sh loops and is the healthcheck
client/ alert.sh, windows/alert.ps1, linux/mutagen.service
data/ runtime state, ignored by git
config/ host key and the sync user's home; back it up
clients/ <device>.pub, read at start
shared/ the synced tree; contents owned by uid 1000
private/ <name>/ per client file, each owned by its own uid from 2000 up
backups/ snapshots named <UTC timestamp>Z, latest is a symlink to the newest
private-backups/ the same for private/