Skip to content
jannoguerPublic

About

Self-hosted SSH file sync

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

83 Commits

Folders and files

Repository files navigation

Cubby

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.

  1. Server
  2. Client
  3. Daemon on boot and alerts
  4. Backups
  5. Clients: add, revoke
  6. Private folders
  7. SFTP
  8. Maintenance
  9. Layout

1. Server

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.

2. Client

Install Mutagen:

brew install mutagen-io/mutagen/mutagen # macOS, Linux
scoop install main/mutagen              # Windows

Binaries without a package manager: releases.

Make a key:

ssh-keygen -t ed25519 -N "" -f ~/.ssh/cubby

Put ~/.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:/shared

Day to day:

mutagen sync list cubby         # status
mutagen sync terminate cubby    # remove the session, files stay
mutagen daemon stop             # any mutagen command starts it again

On 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.

3. Daemon on boot and alerts

Windows, macOS:

mutagen daemon register

Linux:

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.service

Alerts: 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 -Force

Linux, 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.

4. Backups

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/.

5. Clients: add, revoke

Add: drop the device's .pub into data/clients/. Revoke: delete it. Then:

docker compose restart server # other clients reconnect on their own

6. Private folders

Each 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/laptop

Warning

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 -

7. SFTP

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 name

Android 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.

8. Maintenance

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 -d

Rotate 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.

9. Layout

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/

About

Self-hosted SSH file sync

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages