Ente Photos has a clean E2E encryption architecture: the server never reads your file bytes. The upload flow is a three-party handshake. The client asks museum (the Go API server) for a pre-signed S3 URL, the client PUTs the encrypted file directly to MinIO using that URL, then tells museum it is done. Museum calls HeadObject to verify the file landed and commits to the database. The design is sound. The self-hosting story is not.
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 fix sounds obvious: set the endpoint to your server's LAN IP. That breaks the other half.
After the phone uploads, museum runs HeadObject using the same S3 client, with the same endpoint. From inside a Docker container, the LAN IP routes to the host and hits a port-published iptables DNAT rule that only fires on traffic arriving from outside the Docker bridge. Container-to-host-IP traffic arrives via the bridge, so the rule does not fire. The packets are dropped. Museum logs OBJECT_SIZE_FETCH_FAILED. This looks like a MinIO misconfiguration. It is not. It is Docker NAT semantics.
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. Testing from inside the container confirms both paths: MinIO's Docker IP works immediately, the LAN IP times out regardless of what /etc/hosts says.
The correct fix is a public_endpoint field in the S3 bucket config. Museum creates two S3 clients per bucket: one using the internal Docker service name for HeadObject and other direct API calls, one using the LAN IP only for generating pre-signed URLs. Internal calls use Docker DNS. External URLs contain the reachable address. The split-horizon DNS problem is solved by making it explicit in config rather than trying to paper over it with host remapping. Once that is in place and you hit the 10 GB storage cap (hardcoded in billing.go, not configurable), call the admin API to swap the subscription to a larger one. The app unblocks immediately, no restart needed.