Scenario

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 requiredendpoint/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 storageAmazon 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:

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.