The S3-compatible Backend
Plug MeshHold into self-hosted apps.
Plug MeshHold into self-hosted apps. Per-vault buckets, AWS Sig v4, multipart upload — Garage-style.
1. Enable the S3 listener
Edit config.yaml:
node:
s3:
enabled: true
listen_addr: "127.0.0.1:3900"
region: "meshhold"
max_put_bytes: 67108864
Restart the daemon.
2. Map a vault to a bucket
meshhold vault s3-alias <vault_id> my-bucket
The alias is the bucket name S3 clients will use. One vault may carry
one alias; pass "" to clear it.
3. Create access keys
meshhold s3-key add --label "ci"
# returns: access_key_id + secret_access_key
The secret is printed once — copy it immediately. You can mint as many keys as you need; each is scoped per-vault by the next step.
4. Grant permissions
meshhold s3-perm grant <access_key_id> <vault_id> read,write
meshhold s3-perm list
<perms> accepts read, write, read+write or the shorthands r,
w, rw. Use s3-perm revoke to drop a grant.
5. Use it from any S3 client
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
aws --endpoint-url http://127.0.0.1:3900 s3 ls s3://my-bucket/
Endpoint cheat-sheet
When wiring third-party apps, these are the values you'll need over and over:
| Setting | Value |
|---|---|
| Endpoint URL | http://127.0.0.1:3900 (or whatever you set as listen_addr) |
| Region | meshhold (the value of node.s3.region) |
| Addressing | Path-style required — endpoint/bucket/key, never bucket.endpoint/key |
| Signature | AWS Sig v4 |
| Multipart upload | Supported (recommended for objects > max_put_bytes) |
| TLS | Plain HTTP on loopback; put nginx in front for TLS |
Apps that hard-code virtual-host addressing (bucket.s3.amazonaws.com)
will fail. Every recipe below sets the path-style flag explicitly.
Publishing media to the public Internet
A few of the recipes below (Mastodon, PeerTube) need user-facing media
URLs that browsers fetch directly. MeshHold's S3 listener is private by
design — bind it to 127.0.0.1 and put nginx in front, restricted to
GET on the buckets that hold public objects:
server {
listen 443 ssl http2;
server_name media.example.com;
# Strip Authorization so anonymous reads work.
location /media-bucket/ {
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:3900;
proxy_set_header Host $host;
proxy_set_header Authorization "";
}
}
Reads against keys whose grant includes read for the anonymous case
will succeed; writes need a signed request and stay rejected. For
finer access control, keep the bucket fully private and serve uploads
through the app's own proxy endpoint instead.
Nextcloud
Nextcloud can use S3 in two modes:
As primary storage (every file in S3)
Edit config/config.php:
'objectstore' => [
'class' => '\\OC\\Files\\ObjectStore\\S3',
'arguments' => [
'bucket' => 'nextcloud',
'autocreate' => false,
'key' => 'GK…',
'secret' => '…',
'hostname' => '127.0.0.1',
'port' => 3900,
'use_ssl' => false,
'region' => 'meshhold',
'use_path_style' => true,
'legacy_auth' => false,
],
],
Mint the key, create the alias, grant read+write, then restart PHP-FPM.
Existing installs can be migrated with occ files:transfer-ownership
into the S3-backed user.
As external storage (per-user mount)
GUI: Settings → Administration → External storage → Amazon S3.
CLI:
sudo -u www-data php occ files_external:create \
"MeshHold" amazons3 amazons3::accesskey \
-c bucket=team-share \
-c hostname=127.0.0.1 \
-c port=3900 \
-c region=meshhold \
-c use_path_style=true \
-c legacy_auth=false \
-c key=GK… -c secret=…
PeerTube
PeerTube needs two buckets — one for the original videos, one for the HLS playlists. Both must be readable to anonymous browsers; front them with the nginx snippet above.
meshhold vault s3-alias <vault_a> peertube-videos
meshhold vault s3-alias <vault_b> peertube-playlists
meshhold s3-key add --label "peertube"
meshhold s3-perm grant <key> <vault_a> read+write
meshhold s3-perm grant <key> <vault_b> read+write
config/production.yaml:
object_storage:
enabled: true
endpoint: 'http://127.0.0.1:3900'
region: 'meshhold'
credentials:
access_key_id: 'GK…'
secret_access_key: '…'
proxify_private_files: false
videos:
bucket_name: 'peertube-videos'
prefix: ''
base_url: 'https://media.example.com/peertube-videos'
streaming_playlists:
bucket_name: 'peertube-playlists'
prefix: ''
base_url: 'https://media.example.com/peertube-playlists'
PeerTube uses multipart for large uploads — make sure max_put_bytes
in config.yaml is high enough for the chunk size you've configured,
or rely on PeerTube's own chunking.
Mastodon
Mastodon stores avatars, media attachments and previews in S3. Like PeerTube, the bucket needs to be reachable from end-user browsers.
meshhold vault s3-alias <vault_id> mastodon-media
meshhold s3-key add --label "mastodon"
meshhold s3-perm grant <key> <vault_id> read+write
.env.production:
S3_ENABLED=true
S3_PROTOCOL=http
S3_ENDPOINT=http://127.0.0.1:3900
S3_REGION=meshhold
S3_BUCKET=mastodon-media
S3_FORCE_SINGLE_REQUEST=false
AWS_ACCESS_KEY_ID=GK…
AWS_SECRET_ACCESS_KEY=…
S3_ALIAS_HOST=media.example.com/mastodon-media
S3_PERMISSION=
S3_ALIAS_HOST is the public-facing URL prefix browsers see in HTML —
point it at the nginx vhost from the publishing section above.
Mastodon hammers small objects; if you back it with MeshHold, expect hundreds of thousands of files per active user and plan vault RF and disk space accordingly.
Matrix (Synapse)
Synapse keeps its media on local disk by default. The
synapse-s3-storage-provider plugin tee's writes to S3 and serves
reads from cache:
pip install synapse-s3-storage-provider
homeserver.yaml:
media_storage_providers:
- module: s3_storage_provider.S3StorageProviderBackend
store_local: true
store_remote: true
store_synchronous: true
config:
bucket: matrix-media
region_name: meshhold
endpoint_url: http://127.0.0.1:3900
access_key_id: GK…
secret_access_key: …
addressing_style: path
The provider keeps the local FS as a cache — run the s3_media_upload
GC tool from cron to prune files that have been safely flushed to S3.
ejabberd (mod_s3_upload)
XMPP file uploads land in S3 via mod_s3_upload. The download URL must
be a public HTTPS host because XMPP clients fetch it directly.
modules:
mod_s3_upload:
bucket_url: "http://127.0.0.1:3900/xmpp-uploads"
access_key_id: "GK…"
access_key: "…"
region: "meshhold"
download_url: "https://media.example.com/xmpp-uploads"
Grant the access key read+write on the bucket; the nginx vhost in
front handles anonymous GETs.
Pleroma
config/prod.secret.exs:
config :pleroma, Pleroma.Upload, uploader: Pleroma.Uploaders.S3
config :pleroma, Pleroma.Uploaders.S3,
bucket: "pleroma-media",
streaming_enabled: true,
public_endpoint: "https://media.example.com/pleroma-media"
config :ex_aws, :s3,
scheme: "http://",
host: "127.0.0.1",
port: 3900,
region: "meshhold",
access_key_id: "GK…",
secret_access_key: "…"
Pleroma's built-in migrator has known issues with non-AWS S3 — if
you're moving an existing install, rclone sync the on-disk media
directory into the bucket first and then flip the uploader.
Lemmy (via pict-rs)
Lemmy delegates image storage to pict-rs
≥4.0. The relevant slice of pict-rs.toml:
[store]
type = "object_storage"
endpoint = "http://127.0.0.1:3900"
use_path_style = true
bucket_name = "lemmy-pictrs"
region = "meshhold"
access_key = "GK…"
secret_key = "…"
Run pict-rs --store object-storage migrate-store ... on an existing
install before switching — pict-rs does not lazily migrate.
Ente
Ente serves photos out of a single bucket; both museum.yaml and
credentials.yaml need updating:
museum.yaml:
s3:
are_local_buckets: true
use_path_style_urls: true
b2-eu-cen:
key: GK…
secret: …
endpoint: http://127.0.0.1:3900
region: meshhold
bucket: ente-photos
CORS rules on the bucket are required; the easiest path is to put
nginx in front of the bucket with the usual Access-Control-Allow-*
headers and skip in-app CORS configuration.
Restic / rclone backups
Both tools speak Sig v4 path-style out of the box.
Restic:
export AWS_ACCESS_KEY_ID=GK…
export AWS_SECRET_ACCESS_KEY=…
restic -r s3:http://127.0.0.1:3900/laptop-backups init
restic -r s3:http://127.0.0.1:3900/laptop-backups backup ~/Documents
rclone (~/.config/rclone/rclone.conf):
[meshhold]
type = s3
provider = Other
access_key_id = GK…
secret_access_key = …
endpoint = http://127.0.0.1:3900
region = meshhold
force_path_style = true
Then rclone sync /home/me/photos meshhold:photos.
Pixelfed, Funkwhale, Misskey, Prismo, OCIS
These speak the same dialect — endpoint, region, path-style, key/secret.
Once the bucket exists and the key has read+write, paste the four
values into the app's S3 config block. The official upstream docs:
- Pixelfed → Object storage — set
PF_OPEN_REGISTRATION-style env varsAWS_*. - Funkwhale → S3 storage.
- Misskey → Object storage — admin panel.
- OCIS.
If something refuses to connect, 90% of the time it's virtual-host addressing — flip the "path-style" toggle and try again.
Static site (the simplest recipe)
For a Jekyll/Hugo _site/ directory:
aws --endpoint-url http://127.0.0.1:3900 \
s3 sync _site/ s3://my-site/ --delete
Front the bucket with nginx (anonymous GET only) and you have a CDN-free static host backed by your mesh.