Ente Photos has a clean E2E encryption architecture: the server never reads your file bytes. The upload flow routes through three parties. The client asks museum (the Go API server) for a pre-signed S3 URL, then PUTs the encrypted file directly to MinIO with museum out of the data path entirely. Museum only re-enters at the end: HeadObject to confirm the object landed, then a database commit. The design is sound. The self-hosting story is trickier than the docs make it look.
The default museum.yaml ships with the MinIO endpoint set to localhost:3200. When museum generates a pre-signed URL, that hostname goes directly into the URL that gets handed to the iOS app. On the phone, localhost is the phone. The PUT goes nowhere. Museum's HeadObject times out. The app spins forever.
The first workaround attempt: extra_hosts in compose.yaml, mapping the LAN IP to MinIO's container IP. This adds a /etc/hosts entry. It does not work. The Go net package checks whether a URL host is a valid IP literal before consulting DNS or /etc/hosts. It is, so it skips resolution entirely and connects to the IP directly. The hosts file entry is never read.
From there I concluded the LAN IP was fundamentally broken from inside Docker and opened a PR against the Ente server adding a public_endpoint field: two S3 clients per bucket, one using the Docker service name for museum's internal HeadObject calls, one using the LAN IP for generating pre-signed URLs.
A reply on the PR corrected the diagnosis. Museum does not need privileged or internal access to MinIO. Docker hairpin NAT routes museum's traffic via the host LAN IP back through the published port just fine. The expected self-hosting setup is one endpoint reachable by both museum and clients. The socat workaround in the quickstart docs is a convenience for getting running without figuring out your IP, not a sign that the LAN IP path is broken. The PR was also touching too much surface area; if a public_endpoint field were genuinely needed, rewriting only the URL at presign time would be the more precise approach.
The actual fix is one line: set endpoint: <server-LAN-IP>:3200 in the S3 bucket config. Museum reaches MinIO via hairpin NAT. Clients get a pre-signed URL with the same address. The extra_hosts failure was real but led to the wrong conclusion about why.
Once that is in place, the 10 GB storage cap surfaces. It is hardcoded in billing.go and not configurable from museum.yaml. Call the admin API to replace the subscription with a larger one. The app unblocks immediately, no restart needed.
The files in MinIO are encrypted blobs named by UUID, one per photo and thumbnail. The decryption keys derive from the account master key, which the server never holds. The Ente CLI handles the full flow: authenticate with museum, pull the encrypted objects, decrypt client-side, write out as JPEG or HEIC with the original album folder structure. The CLI ships with ente.io as its default endpoint. Before running ente account add, create ~/.ente/config.yaml with endpoint.api pointing at the local museum address. Without it, the account lookup returns a 404 that reads like the account does not exist.