Cross-Site S3 Sync with SeaweedFS on FreeBSD: Push From Anywhere, Pull at Home (Part 2: The Build)
The why is in Part 1: The Idea. This is just the build.
- Public site A (the phone’s write target) + internal NATed site B (pull mirror).
- Content flows A→B; deletions made on B are replayed to A.
- Bucket:
data. - Done on SeaweedFS 4.x and FreeBSD 15.1-RELEASE.
Hosts
| Host | IP | Role |
|---|---|---|
| swa | 10.10.0.11 | public write target, s3a.example.org |
| swb | 192.168.20.11 | home box, NAT egress 203.0.113.50 |
Ports
9333/19333 master 8080/18080 volume — localhost only 8888/18888 filer — swa allows 203.0.113.50 only 9090 S3 HTTPS 8333 blocked 8181 disabled
1. Both boxes
pkg install seaweedfs rclone jq
sysrc ntpd_enable=YES ntpd_sync_on_start=YES
service ntpd start
mkdir -p /var/db/seaweedfs/data
chown -R seaweedfs:seaweedfs /var/db/seaweedfs
Configs go in /usr/local/etc/seaweedfs/. Drop a TLS cert/key per site at seaweedfs.crt / .key.
2. swa (public)
# /etc/rc.conf
seaweedfs_enable="YES"
seaweedfs_user="seaweedfs"
seaweedfs_group="seaweedfs"
seaweedfs_options="server \
-ip=10.10.0.11 \
-dir=/var/db/seaweedfs/data \
-volume.max=200 \
-s3 \
-s3.config=/usr/local/etc/seaweedfs/s3.json \
-s3.cert.file=/usr/local/etc/seaweedfs/seaweedfs.crt \
-s3.key.file=/usr/local/etc/seaweedfs/seaweedfs.key \
-s3.port.https 9090 \
-s3.port.iceberg=0"
seaweedfs_syslog_output_enable="YES"
service seaweedfs start
echo "s3.bucket.create -name data" | weed shell -master=10.10.0.11:9333
3. swb (home)
Same block, IP 192.168.20.11.
service seaweedfs start
echo "s3.bucket.create -name data" | weed shell -master=192.168.20.11:9333
4. S3 identities
⚠️ MAKE SURE TO CHANGE THE SECRET KEYS! ⚠️
s3.json on swa — syncuser needs Write:data (delete is part of Write):
{
"identities": [
{ "name": "admin", "credentials": [{ "accessKey": "AdminAccessA", "secretKey": "AdminSecretA" }],
"actions": [ "Admin", "Read", "List", "Tagging", "Write" ] },
{ "name": "syncuser", "credentials": [{ "accessKey": "SyncAccessA", "secretKey": "SyncSecretA" }],
"actions": [ "List:data", "Read:data", "Write:data" ] }
]
}
s3.json on swb — deletions on B done with admin:
{
"identities": [
{ "name": "admin", "credentials": [{ "accessKey": "AdminAccessB", "secretKey": "AdminSecretB" }],
"actions": [ "Admin", "Read", "List", "Tagging", "Write" ] },
{ "name": "syncuser", "credentials": [{ "accessKey": "SyncAccessB", "secretKey": "SyncSecretB" }],
"actions": [ "List:data", "Read:data" ] }
]
}
Restart seaweedfs on both.
5. New-file webhook (both sites)
weed scaffold -config=notification > /usr/local/etc/seaweedfs/notification.toml
[notification.webhook]
enabled = true
endpoint = "https://events.example.org/seaweed-hook"
bearer_token = "change-me-per-site"
event_types = ["create"]
path_prefixes = ["/buckets/data"]
timeout_seconds = 10
max_retries = 3
Restart the filer. Have the receiver ignore keys with /.uploads/ in them (multipart parts).
6. pf on swa
ext_if = "vtnet0"
b_egress = "203.0.113.50"
pass in quick on $ext_if proto tcp from $b_egress to ($ext_if) port { 8888, 18888 }
pass in quick on $ext_if proto tcp from $b_egress to ($ext_if) port 9090
pass in quick on $ext_if proto tcp to ($ext_if) port 9090 # S3 for the phone / apps
block in quick on $ext_if proto tcp to ($ext_if) port { 8333, 9333, 19333, 8080, 18080 }
# from swb
nc -vz s3a.example.org 8888
nc -vz s3a.example.org 18888
7. Content sync A→B (on swb)
#!/bin/sh
# /usr/local/etc/rc.d/seaweedsync
# PROVIDE: seaweedsync
# REQUIRE: NETWORKING seaweedfs
# KEYWORD: shutdown
. /etc/rc.subr
name=seaweedsync
rcvar=seaweedsync_enable
load_rc_config $name
: ${seaweedsync_enable:="NO"}
: ${seaweedsync_user:="seaweedfs"}
: ${seaweedsync_options:=""}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-P ${pidfile} -R 15 -S -T ${name} -u ${seaweedsync_user} \
/usr/local/bin/weed filer.sync ${seaweedsync_options}"
run_rc_command "$1"
chmod 755 /usr/local/etc/rc.d/seaweedsync
# /etc/rc.conf on swb, appended
seaweedsync_enable="YES"
seaweedsync_options="-isActivePassive \
-a s3a.example.org:8888 -a.filerProxy \
-b 127.0.0.1:8888 \
-a.path /buckets/data -b.path /buckets/data"
Want files to stick around on B after you delete them on A? Append -b.doDeleteFiles=false. Already have photos on A? Add -a.fromTsMs=1 on the first run, or run rclone copy primary:data secondary:data once.
service seaweedsync start
tail -f /var/log/messages | grep seaweedsync
8. Delete replay B→A (on swb)
⚠️ This isn’t the most solid way of cleaning up your node A ⚠️ If you have more solid ideas, let me know and I will create an updated version of this how-to.
/usr/local/etc/seaweedfs/rclone.conf (chmod 600, owner seaweedfs):
[primary]
type = s3
provider = SeaweedFS
endpoint = https://s3a.example.org:9090
access_key_id = SyncAccessA
secret_access_key = SyncSecretA
#!/bin/sh
# /usr/local/sbin/seaweedfs-delprop.sh
set -eu
RCLONE=/usr/local/bin/rclone
CONF=/usr/local/etc/seaweedfs/rclone.conf
/usr/local/bin/weed filer.meta.tail -filer=127.0.0.1:8888 -pathPrefix=/buckets/data -timeAgo=10m \
| jq --unbuffered -r '
select(.eventNotification.oldEntry != null
and .eventNotification.newEntry == null
and .eventNotification.isFromOtherCluster == false
and .eventNotification.oldEntry.isDirectory == false)
| .directory + "/" + .eventNotification.oldEntry.name' \
| while IFS= read -r path; do
key=${path#/buckets/data/}
[ "$key" = "$path" ] && continue
"$RCLONE" deletefile "primary:data/${key}" --config "$CONF" --s3-no-check-bucket || true
done
Self-signed cert on A: add --no-check-certificate to the deletefile line. The isFromOtherCluster == false filter means it only fires on deletions you make on B; -timeAgo=10m replays recent events on restart (deletes are idempotent).
#!/bin/sh
# /usr/local/etc/rc.d/seaweeddelprop
# PROVIDE: seaweeddelprop
# REQUIRE: NETWORKING seaweedfs
# KEYWORD: shutdown
. /etc/rc.subr
name=seaweeddelprop
rcvar=seaweeddelprop_enable
load_rc_config $name
: ${seaweeddelprop_enable:="NO"}
: ${seaweeddelprop_user:="seaweedfs"}
pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
command_args="-P ${pidfile} -R 15 -S -T ${name} -u ${seaweeddelprop_user} \
/usr/local/sbin/seaweedfs-delprop.sh"
run_rc_command "$1"
chmod 755 /usr/local/etc/rc.d/seaweeddelprop /usr/local/sbin/seaweedfs-delprop.sh
sysrc seaweeddelprop_enable=YES
service seaweeddelprop start
9. Check it works
primaryadmin / secondaryadmin are your own admin S3 remotes for A and B.
rclone copyto ./hi.txt primaryadmin:data/hi.txt
rclone ls secondary:data # appears on B
rclone deletefile secondaryadmin:data/hi.txt
rclone ls primaryadmin:data # gone from A within seconds
weed filer.sync.verify -isActivePassive \
-a s3a.example.org:8888 -b 127.0.0.1:8888 \
-a.path /buckets/data -b.path /buckets/data \
-modifiedTimeAgo 15m
10. Point PhotoSync at it
On the phone, add an S3 target in PhotoSync:
- Endpoint:
https://s3a.example.org:9090 - Bucket:
data - Access / secret key: one with
Write:dataon A — useadmin, or add a dedicated identity ins3.json - SSL: on
Hit sync. Photos upload to the public box from wherever you are, and the home box pulls a full copy down in the background.
11. Harden
mTLS: weed scaffold -config=security on both, fill [grpc.*], add -a.security=/path/to/securityA.toml to filer.sync.
Or WireGuard swb → swa; point filer.sync and the primary rclone endpoint at the tunnel address and close 8888/18888 on the public interface.
12. When something’s off
sockstat -46l | grep weedfor listenersweed shell→cluster.check,volume.list,fs.du /buckets/data- Sync connection loops → check pf/NAT with
nc -vz - Deletions on B not reaching A →
service seaweeddelprop status, runweed filer.meta.tail -filer=127.0.0.1:8888 -pathPrefix=/buckets/databy hand to see the delete events, check swb syslog and that A’ssyncuserhasWrite:data - Signature errors → check
chronyc tracking - Upgrades: same release on both, home box first