
Ente Photos Self-Hosted Setup with Tailscale
Overview
This app is something I have wanted to try out for some time, but failed several times to implement fully in the past. There was always some login/upload/permisssions issues I couldn’t seem to crack even with AI help. I recently had some success and wanted to document this. Make sure you see the Mobile section for some learnings if you go this route.
Ente Photos is a self-hosted, end-to-end encrypted photo backup solution - think Google Photos, but you control the infrastructure and encryption keys. This guide documents deploying the full Ente stack using Docker, Tailscale for HTTPS access, PostgreSQL for metadata, and MinIO for S3-compatible object storage.
This deployment follows Ente’s official self-hosting documentation, adapted for a homelab environment with Tailscale networking and NFS storage.
What you’ll end up with:
- Ente Photos API server (Museum) accessible via Tailscale HTTPS
- PostgreSQL database for user accounts and metadata
- MinIO S3-compatible storage for photos and videos
- Mobile app support (iOS/Android)
- Zero reverse proxy complexity thanks to Tailscale serve
Prerequisites
Before starting, ensure you have:
- Docker host with Docker Compose
- NFS mount for persistent storage (or local storage)
- Tailscale network configured
- Portainer (optional, but makes stack management easier)
- Tailscale auth key with
tag:containerpermission
Software versions used:
- PostgreSQL 15
- Ente Server (ghcr.io/ente-io/server:latest)
- MinIO (latest)
- Tailscale (latest)
Architecture
The stack consists of four main components sharing a single Tailscale network namespace:
- Museum - Ente Photos API server
- PostgreSQL - Primary database storing user accounts and photo metadata
- MinIO - S3-compatible object storage for actual photos and videos
- Tailscale - Provides secure HTTPS access via MagicDNS and automatic certificates
All containers use network_mode: service:ente-tailscale, meaning they share the Tailscale container’s network namespace. This eliminates the need for a reverse proxy while providing automatic HTTPS via Tailscale’s certificates.
Directory Structure
On your Docker host, create the required directory structure:
# Create all required directories
sudo mkdir -p /mnt/docker-data/ente/{config,museum,postgres,minio,tailscale}
# Set permissions (adjust UID/GID as needed)
sudo chown -R 1000:1000 /mnt/docker-data/ente/
sudo chmod 755 /mnt/docker-data/ente/*
Directory purposes:
config/- Contains credentials.yaml and museum.yamlmuseum/- Museum application data (read-only mount)postgres/- PostgreSQL database filesminio/- MinIO object storage datatailscale/- Tailscale state and configuration
Secret Configuration
Ente uses a two-file approach for secrets:
.env- Docker environment variables (database password, MinIO credentials)credentials.yaml- Ente-specific secrets (encryption keys, S3 configuration)
Important: Some values must match between these two files.
Generate Secrets
First, generate four unique secrets for credentials:
# Generate 4 unique base64 secrets
openssl rand -base64 32 # SECRET_1 - PostgreSQL password
openssl rand -base64 32 # SECRET_2 - Encryption key
openssl rand -base64 32 # SECRET_3 - Hash key
openssl rand -base64 32 # SECRET_4 - JWT secret
Also choose:
- MinIO admin username (e.g.,
minio-admin) - MinIO admin password (use
openssl rand -base64 24)
Get a Tailscale auth key from https://login.tailscale.com/admin/settings/keys
Create .env File
Create .env with your generated values:
# Tailscale
TS_AUTHKEY=tskey-auth-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# PostgreSQL Database
POSTGRES_PASSWORD=SECRET_1 # Your generated SECRET_1
POSTGRES_DB=ente_db
POSTGRES_USER=pguser
# MinIO S3 Storage
MINIO_ROOT_USER=minio-admin # Your chosen username
MINIO_ROOT_PASSWORD=SECRET_5 # Your chosen MinIO password
Create credentials.yaml
This file contains Ente-specific encryption keys and S3 configuration. The encryption keys in this file are immutable - changing them after creating accounts will break all logins and make photos undecryptable.
# Database credentials - MUST MATCH .env POSTGRES_PASSWORD
db:
host: localhost
port: 5432
name: ente_db
user: pguser
password: SECRET_1 # MUST MATCH .env POSTGRES_PASSWORD
# Encryption keys - IMMUTABLE after first account creation
key:
encryption: SECRET_2 # Encrypts all photo metadata
hash: SECRET_3 # Hashes user emails for lookups
# JWT authentication
jwt:
secret: SECRET_4
# S3 bucket configuration
# All three buckets point to the same MinIO instance
s3:
b2-eu-cen:
key: minio-admin # MUST MATCH .env MINIO_ROOT_USER
secret: SECRET_5 # MUST MATCH .env MINIO_ROOT_PASSWORD
endpoint: https://ente.your-tailnet.ts.net:3200
region: eu-cen
bucket: b2-eu-cen
wasabi-eu-central-2-v3:
key: minio-admin # MUST MATCH .env MINIO_ROOT_USER
secret: SECRET_5 # MUST MATCH .env MINIO_ROOT_PASSWORD
endpoint: https://ente.your-tailnet.ts.net:3200
region: eu-central-2
bucket: wasabi-eu-central-2-v3
scw-eu-fr-v3:
key: minio-admin # MUST MATCH .env MINIO_ROOT_USER
secret: SECRET_5 # MUST MATCH .env MINIO_ROOT_PASSWORD
endpoint: https://ente.your-tailnet.ts.net:3200
region: fr-par
bucket: scw-eu-fr-v3
Why three buckets? Ente’s architecture supports multiple S3 providers for redundancy. In a homelab setup, all three are on the same MinIO server - they’re just different buckets. In a production deployment, these would be different S3 providers like Backblaze B2, Wasabi, and Scaleway.
Upload credentials.yaml to your Docker host:
scp credentials.yaml your-docker-host:/mnt/docker-data/ente/config/
Important: credentials.yaml is Immutable
The encryption keys in credentials.yaml (key.encryption, key.hash, and jwt.secret) are cryptographic keys that cannot be changed after creating user accounts:
key.hash- Hashes user emails for database lookups. Changing this means no existing users can log in.key.encryption- Encrypts all photo metadata. Changing this makes existing photos undecryptable.jwt.secret- Signs authentication tokens. Changing this invalidates all sessions.
If you lose or change these keys: All users lose access to their accounts and uploaded photos become permanently undecryptable.
Backup strategy:
# Create timestamped backup after initial setup
sudo cp /mnt/docker-data/ente/config/credentials.yaml \
/mnt/docker-data/ente/config/credentials.yaml.backup-$(date +%Y%m%d-%H%M%S)
# Store backups in multiple locations:
# 1. NFS mount (already backed up to NAS)
# 2. Password manager (1Password, Bitwarden, etc.)
# 3. Encrypted USB drive in safe location
Create DNS Configuration
Create resolv.conf that uses Tailscale MagicDNS instead of Docker DNS:
echo 'nameserver 100.100.100.100' | sudo tee /mnt/docker-data/ente/resolv.conf
Why Tailscale DNS? Containers using network_mode: service:tailscale share the Tailscale container’s network namespace. Docker DNS (127.0.0.11) doesn’t have access to Tailscale MagicDNS from within this shared namespace, so museum can’t resolve *.ts.net hostnames for S3 access. Using Tailscale MagicDNS (100.100.100.100) solves this.
Docker Compose Configuration
Create docker-compose.yml with the following configuration:
services:
ente-tailscale:
image: tailscale/tailscale:latest
container_name: ente-tailscale
hostname: ente
command: sh -c "tailscaled & sleep 5 && tailscale up --authkey=$$TS_AUTHKEY --hostname=ente --advertise-tags=tag:container --accept-dns=false && tailscale serve --bg --https=443 --set-path=/ 3000 && tailscale serve --bg --https=443 --set-path=/api 8080 && tailscale serve --bg --https=3200 9000 && sleep infinity"
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_STATE_DIR=/var/lib/tailscale
volumes:
- /dev/net/tun:/dev/net/tun
- /mnt/docker-data/ente/tailscale:/var/lib/tailscale
- /mnt/docker-data/ente/resolv.conf:/etc/resolv.conf:ro
cap_add:
- NET_ADMIN
- SYS_MODULE
restart: unless-stopped
web:
image: ghcr.io/ente-io/web:latest
container_name: ente-web
environment:
- ENTE_API_ORIGIN=http://localhost:8080
volumes:
- /mnt/docker-data/ente/resolv.conf:/etc/resolv.conf:ro
network_mode: service:ente-tailscale
depends_on:
- ente-tailscale
- museum
restart: unless-stopped
museum:
image: ghcr.io/ente-io/server:latest
container_name: ente-museum
environment:
- ENTE_DB_HOST=localhost
- ENTE_DB_PORT=5432
- ENTE_DB_NAME=${POSTGRES_DB:-ente_db}
- ENTE_DB_USER=${POSTGRES_USER:-pguser}
- ENTE_DB_PASSWORD=${POSTGRES_PASSWORD}
- ENTE_DB_SSLMODE=disable
volumes:
- /mnt/docker-data/ente/museum:/data:ro
- /mnt/docker-data/ente/config/museum.yaml:/museum.yaml:ro
- /mnt/docker-data/ente/config/credentials.yaml:/credentials.yaml:ro
- /mnt/docker-data/ente/resolv.conf:/etc/resolv.conf:ro
network_mode: service:ente-tailscale
depends_on:
- ente-tailscale
- postgres
- minio
restart: unless-stopped
postgres:
image: postgres:15
container_name: ente-postgres
environment:
- POSTGRES_DB=${POSTGRES_DB:-ente_db}
- POSTGRES_USER=${POSTGRES_USER:-pguser}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- /mnt/docker-data/ente/postgres:/var/lib/postgresql/data
- /mnt/docker-data/ente/resolv.conf:/etc/resolv.conf:ro
network_mode: service:ente-tailscale
depends_on:
- ente-tailscale
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pguser} -d ${POSTGRES_DB:-ente_db}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: ente-minio
command: server /data --address :9000 --console-address :9001
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-changeme}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-changeme1234}
volumes:
- /mnt/docker-data/ente/minio:/data
- /mnt/docker-data/ente/resolv.conf:/etc/resolv.conf:ro
network_mode: service:ente-tailscale
depends_on:
- ente-tailscale
restart: unless-stopped
minio-setup:
image: minio/mc:latest
container_name: ente-minio-setup
entrypoint: >
/bin/sh -c "
sleep 10;
/usr/bin/mc alias set myminio http://localhost:9000 ${MINIO_ROOT_USER:-changeme} ${MINIO_ROOT_PASSWORD:-changeme1234};
/usr/bin/mc mb --ignore-existing myminio/b2-eu-cen;
/usr/bin/mc mb --ignore-existing myminio/wasabi-eu-central-2-v3;
/usr/bin/mc mb --ignore-existing myminio/scw-eu-fr-v3;
exit 0;
"
network_mode: service:ente-tailscale
depends_on:
- ente-tailscale
- minio
restart: "no"
Key configuration details:
- Tailscale serve commands - Map HTTPS ports:
- Port 443 / → Port 3000 (web interface)
- Port 443 /api → Port 8080 (museum API)
- Port 3200 → Port 9000 (MinIO S3, needs browser access for presigned URLs)
-
Network mode sharing - All containers use
network_mode: service:ente-tailscaleto share the Tailscale network namespace -
DNS resolution - Custom
resolv.confmounted withnameserver 100.100.100.100for Tailscale MagicDNS - MinIO setup - One-time container creates three buckets and exits
Deployment
Via Portainer (Optional)
- Log into Portainer
- Navigate to Stacks → Add stack
- Choose Repository as build method
- Configure:
- Name:
ente-photos - Repository URL: Your homelab repo
- Repository reference:
refs/heads/main - Compose path:
ente/docker-compose.yml
- Name:
- Add environment variables from
.env - Click Deploy the stack
Via Docker Compose
Alternatively, deploy directly:
# Navigate to stack directory
cd /path/to/ente/
# Deploy stack
docker compose up -d
# Watch startup logs
docker compose logs -f
Post-Deployment Verification
Check that all containers are running:
docker ps | grep ente
You should see 5 containers:
- ente-tailscale (running)
- ente-web (running)
- ente-museum (running)
- ente-postgres (running)
- ente-minio (running)
- ente-minio-setup (exited - this is expected)
Check logs for each service:
# Museum startup
docker logs ente-museum
# PostgreSQL
docker logs ente-postgres
# MinIO
docker logs ente-minio
# Tailscale connection
docker logs ente-tailscale
Verify MinIO buckets were created:
docker logs ente-minio-setup
Should show three buckets created:
- b2-eu-cen
- wasabi-eu-central-2-v3
- scw-eu-fr-v3
Test Tailscale HTTPS access from any device on your Tailnet:
https://ente.your-tailnet.ts.net
The first HTTPS request may take 1-2 minutes while Tailscale provisions an SSL certificate.
Important: After initial deployment, restart Tailscale to refresh peer discovery:
docker restart ente-tailscale
docker restart ente-museum
First Account Setup
Navigate to https://ente.your-tailnet.ts.net
Click the login page 8 times to expose the developer settings (or this may pop-up as you try to fill out the create account details). This lets you set API for your self-hosted instance. You’ll need to do this for the mobile/desktop apps as well and any time you access it from a new device or after clearing browser caches for the site.
The text to enter would be: https://ente.your-tailnet.ts.net/api
Getting the Verification Code
Without SMTP configured (common for homelab), verification codes appear in museum logs instead of email:
docker logs ente-museum 2>&1 | grep "Verification code" | tail -1
You’ll see: Verification code: 123456
Enter this 6-digit code to complete signup and access your account.
Admin Configuration
To grant unlimited storage to your account, you need both of these steps:
Step 1: Find Your User ID
docker exec -it ente-postgres psql -U pguser -d ente_db \
-c "SELECT user_id FROM users WHERE email='[email protected]';"
Note the user_id returned (e.g., 12345).
Step 2: Update Database Storage
# Set storage to 100TB (109951162777600 bytes)
docker exec ente-postgres psql -U pguser -d ente_db \
-c "UPDATE subscriptions SET storage = 109951162777600 WHERE user_id = 12345;"
Optional: Add to museum.yaml Admin List
Create or edit /mnt/docker-data/ente/config/museum.yaml:
internal:
admins:
- 12345 # Your user_id from step 1
Restart museum:
docker restart ente-museum
Why both steps? The museum.yaml admins list grants admin privileges (user management, etc.), but the UI reads storage quota from the subscriptions.storage database field. You need to update the database for unlimited storage to actually display and be enforced.
Refresh the Ente web page - you should now see “100 TB” instead of “10 GB” storage.
Troubleshooting
Museum Won’t Start
Check database connection:
docker exec ente-museum cat /credentials.yaml
Verify db.password matches your .env POSTGRES_PASSWORD.
Check logs for database errors:
docker logs ente-museum | grep -i error
Upload Failures at 97-99%
Most common cause: DNS resolution issue.
Symptoms:
- Uploads stall at 97-99%
- Museum logs show
nslookup: connection timed out; no servers could be reached - Error:
OBJECT_SIZE_FETCH_FAILED
Solution:
# Verify resolv.conf uses Tailscale DNS
cat /mnt/docker-data/ente/resolv.conf
# Should be: nameserver 100.100.100.100
# NOT: nameserver 127.0.0.11
# If wrong, recreate:
echo 'nameserver 100.100.100.100' | sudo tee /mnt/docker-data/ente/resolv.conf
# Restart containers
docker restart ente-tailscale ente-museum ente-postgres ente-minio
Test DNS resolution from museum container:
docker exec ente-museum nslookup ente.your-tailnet.ts.net
# Should return Tailscale IP (100.x.x.x)
Login Returns 404 Error
Symptoms:
- Login fails with HTTP 404
- Museum logs show:
GetUserIDWithEmail → sql: no rows in result set - User exists in database but can’t log in
Root cause: The key.hash value in credentials.yaml changed after the account was created. Email lookups hash the email using this key, then search for that hash in the database. If the key changed, hashes don’t match.
Verification:
docker logs ente-museum 2>&1 | grep "GetUserIDWithEmail"
# You'll see: "Caused by: sql: no rows in result set"
Solution 1: Create New Account
The simplest solution is to create a fresh account with the current (stable) credentials.yaml:
- Go to
https://ente.your-tailnet.ts.net - Click “Sign Up” with same or different email
- New account will work with current hash key
- Follow admin storage steps above
Solution 2: Restore Original credentials.yaml
If you have a backup of the original credentials.yaml from when the account was created:
# Restore backup
sudo cp /mnt/docker-data/ente/config/credentials.yaml.backup-YYYYMMDD \
/mnt/docker-data/ente/config/credentials.yaml
# Restart museum
docker restart ente-museum
Verification Code Not Received
Without SMTP configured, codes only appear in logs:
docker logs ente-museum 2>&1 | grep "Verification code" | tail -1
Format is a 6-digit numeric code.
MinIO Connection Errors
Symptoms:
- Can’t upload photos
- Errors about S3 connection failures
Causes and solutions:
1. MinIO credentials mismatch
# Verify MinIO credentials in credentials.yaml match .env
docker exec ente-museum cat /credentials.yaml | grep -A 10 "s3:"
All S3 key fields must match .env MINIO_ROOT_USER.
All S3 secret fields must match .env MINIO_ROOT_PASSWORD.
2. Buckets not created
# Verify buckets exist
docker exec ente-minio ls /data
# Should see: b2-eu-cen/ wasabi-eu-central-2-v3/ scw-eu-fr-v3/
If missing, recreate by restarting minio-setup:
docker compose up -d ente-minio-setup
3. MinIO endpoint not browser-accessible
Museum generates presigned S3 URLs that the browser uses to upload directly to MinIO. The endpoint in credentials.yaml must be accessible from the browser (not just from within containers):
- Error:
endpoint: http://localhost:9000(browser can’t reach localhost in container) - Solution:
endpoint: https://ente.your-tailnet.ts.net:3200(browser can reach via Tailscale)
The Tailscale serve command proxies port 3200 → MinIO port 9000 with HTTPS.
Tailscale Connection Timeout
Symptoms: Can access Ente but webhooks/OIDC fail
Solution:
# Refresh peer discovery
docker restart ente-tailscale
docker restart ente-museum
# Verify Tailscale can reach other nodes
docker exec ente-tailscale ping -c 3 another-device.your-tailnet.ts.net
Mobile Apps
- iOS: Download Ente Photos from App Store
- Android: Download from Play Store or F-Droid
Configuration:
Open the app and click somewhere on the login page 8 times:
- Custom server endpoint:
https://ente.your-tailnet.ts.net/api - Log in with account created above
Your photos sync across all devices using the same account.
NOTE:
Be cautious for iOS on uploading your whole camera roll at once. I have not devled into this fully, but the app creates encrypted versions of your photos on the device (doubling the space your photos take up). You can supposedly reclaim the space afterwards, but in my experience this tried to delete my iCloud versions for Apple Photos, which I did not want. Better to upload via macOS web app or desktop app and then log into your mobile account. Then it will recognize you already have everything backed up and you can start from new photos. You may want this behavior if you are fully migrating from iCloud to Ente Photos, but I am still testing things so this was not desirable for me.
Key Lessons
Lesson 1: Encryption Keys Are Immutable
The encryption keys in credentials.yaml (key.encryption, key.hash, and jwt.secret) cannot be changed after creating user accounts.
What each key does:
key.hash- Hashes user emails for database lookupskey.encryption- Encrypts all photo metadatajwt.secret- Signs authentication tokens
What happens if you change them:
- All users can’t log in (email hash mismatch)
- All photos become undecryptable
- Complete data loss with no recovery
Protection strategy:
- Generate once during initial setup
- Backup to 3+ locations immediately (NFS, password manager, encrypted USB)
- Never modify after first account creation
Lesson 2: DNS Resolution with Network Mode
Containers using network_mode: service:tailscale have unique DNS requirements.
The issue: Docker DNS (127.0.0.11) can’t resolve *.ts.net hostnames from within the shared Tailscale network namespace.
The solution: Use Tailscale MagicDNS (100.100.100.100) by mounting a custom resolv.conf.
Symptoms when wrong:
- Uploads fail at 97-99%
- Error:
OBJECT_SIZE_FETCH_FAILED - Logs show:
nslookup: connection timed out; no servers could be reached
Fix: Mount /etc/resolv.conf with nameserver 100.100.100.100 in all containers.
Lesson 3: MinIO Endpoint Must Be Browser-Accessible
Museum generates presigned S3 URLs that the browser uses to upload directly to MinIO.
The issue: The S3 endpoint in credentials.yaml must be reachable from the user’s browser, not just from containers.
Wrong configuration:
endpoint: http://localhost:9000 # Browser can't reach this
Right configuration:
endpoint: https://ente.your-tailnet.ts.net:3200 # Browser can reach via Tailscale
The Tailscale serve command proxies port 3200 → MinIO port 9000 with HTTPS, making MinIO accessible to browsers on your Tailnet.
Lesson 4: Admin Storage Requires Two Steps
Setting yourself as admin in museum.yaml alone doesn’t grant unlimited storage.
Wrong assumption: Adding your user_id to museum.yaml admins list gives unlimited storage.
Reality: The UI reads storage quota from the subscriptions.storage database field.
Both steps required:
- Add to
museum.yamladmins list (grants admin privileges) - Update database:
UPDATE subscriptions SET storage = 109951162777600
Without step 2, the UI still shows “10 GB free plan” even though you’re listed as admin.
Lesson 5: File vs Directory Mount Issues
Docker doesn’t fail gracefully when mounting files as directories.
The issue: If credentials.yaml gets created as a directory instead of a file (e.g., during testing), Docker silently mounts it but museum can’t read the config.
Symptoms:
- 404 errors on login
- No obvious errors in logs
- Container seems to be running fine
Verification:
# Check if it's actually a file
file /mnt/docker-data/ente/config/credentials.yaml
# Should say: "ASCII text", not "directory"
Prevention: Always verify files are files before first deployment.
Limitations
This setup works well for:
- Tailscale-accessible users only
- Homelab/personal use cases
- Users who value privacy and data sovereignty
- Mobile photo backup across multiple devices
Not suitable for:
- Public-facing photo sharing service
- Users without Tailscale access
- (Note: Ente supports external S3 providers like Backblaze B2 or Wasabi for production deployments)
Useful database queries for administration:
# List all users
docker exec ente-postgres psql -U pguser -d ente_db \
-c "SELECT user_id, email FROM users;"
# Check storage quota for user
docker exec ente-postgres psql -U pguser -d ente_db \
-c "SELECT user_id, storage FROM subscriptions WHERE user_id = 12345;"
# View recent museum logs
docker logs ente-museum --tail 50
# Check MinIO disk usage
docker exec ente-minio du -sh /data/*
# Backup credentials.yaml
sudo cp /mnt/docker-data/ente/config/credentials.yaml \
/mnt/docker-data/ente/config/credentials.yaml.backup-$(date +%Y%m%d-%H%M%S)
AI Influence Level: AIL2
Resources:
- Official Ente self-hosting docs: https://ente.io/help/self-hosting/
- Ente GitHub: https://github.com/ente-io/ente
- Tailscale serve guide: https://ente.io/help/self-hosting/guides/tailscale