TLDR: I write a Flutter app in a container on a Hetzner box and run my agents there. Mutagen copies every change to my Mac, where the iOS Simulator and flutter run build and run it. Each project gets its own container. I use the SSH transport, not the Docker one, because the container drops the capability the Docker agent needs. I sync only apps/mobile and list the build and cache folders to ignore by hand, because --ignore-vcs skips .git but not .gitignore.
I write code in a container on a Hetzner box, but I build the app on my Mac. The split sounds annoying. It is not. Mutagen keeps both sides the same, and I stop thinking about it.
The problem
My mobile app needs the repo in two places at once. On the Mac, so Xcode, the iOS Simulator, and the Android emulator can build and run the app from real files. In the box, so I can write the code and run my agents on the server, away from my laptop.
I could clone twice and copy files by hand, but the two copies drift apart in minutes. I want one source of truth that lives in both places and stays the same.
What Mutagen does
Mutagen watches both sides and syncs them. I save a file in the box and it shows up on the Mac about a second later. I save on the Mac and it goes the other way. No manual copy, no rsync cron job.
It runs in the background. Start it once and forget it.
How I connect: SSH, not Docker
Mutagen can reach a container two ways. I use the SSH transport, not the Docker one.
The Docker transport copies its agent in as root, then changes the owner. That needs a capability my box drops for every container, so it just fails.
The SSH transport writes its agent as my normal user, into that user’s home, so the owner is already correct. Nothing to fix, nothing to grant. It fits the locked-down container.
There is no open port either. Mutagen reaches the container by its stable name through a small SSH command, so I never look up a port or recheck it after an update.
The SSH config on the Mac
This is the one block I add to ~/.ssh/config:
Host acme-box User ubuntu IdentityFile ~/.ssh/acme_box IdentitiesOnly yes ProxyCommand ssh [email protected] docker exec -i -u ubuntu box-acme /usr/sbin/sshd -i -f /home/ubuntu/.ssh/sshd_configIdentitiesOnly yes makes SSH offer only this one key, so auth stays predictable.
I make a key for this sync and nothing else, so it is easy to reason about and easy to revoke:
ssh-keygen -t ed25519 -f ~/.ssh/acme_box -C "acme-box sync" -N ""Only the public half ever reaches the container.
Creating the sync
I sync only the app folder, not the whole monorepo, and I ignore every build and cache folder. Those are machine specific, and each side makes its own.
mutagen sync create --name=acme-mobile \ acme-box:/workspace/apps/mobile \ ~/projects/acme-mobile \ --ignore-vcs \ --ignore=.dart_tool \ --ignore=build \ --ignore=.flutter-plugins \ --ignore=.flutter-plugins-dependencies \ --ignore=Pods \ --ignore=.symlinks \ --ignore=.gradle \ --ignore=node_modules \ --ignore=.idea \ --ignore='*.iml' \ --ignore=.DS_StoreOne thing that bit me: --ignore-vcs only skips .git. It does not read .gitignore. So I list the caches myself, or Mutagen happily syncs an 800 MB store.
The patterns match by name at any depth. Pods catches ios/Pods, build catches android/app/build, and so on. No full paths needed.
Driving it day to day
mutagen sync list acme-mobile # status, file counts, conflictsmutagen sync monitor acme-mobile # live progressmutagen sync flush acme-mobile # force a round and waitmutagen sync pause acme-mobilemutagen sync resume acme-mobilemutagen sync terminate acme-mobilecreate returns right away and syncs in the background. I wait for the Watching for changes line before I expect files. Sessions do not change in place, so to edit the path or the ignores I terminate and create again.
The result
I edit in the box with my editor and my agents. Changes land on the Mac in about a second, and flutter run hot reloads them. Each side builds its own dependencies and never shares build output. One repo, two places, always the same.