Part 2: Deploying Ente Photos with Backblaze B2

This follows the previous post on setting up Coolify and Vaultwarden. This time, the focus shifts to building a self-hosted photo backup system with end-to-end encryption.

What is Ente Photos?

Ente is a fully open source, end-to-end encrypted alternative to Google Photos and iCloud Photos, with cross-platform support across iOS, Android, web, Mac, Windows, and Linux. A standout feature is on-device machine learning for face recognition — processing happens locally on the phone rather than server-side, preserving privacy while still enabling smart search.

The system is made up of two containerized components: Museum (the backend API) and Web (the frontend). Rather than storing photos directly on the server's disk, this setup uses S3-compatible object storage, separating compute from storage.

What is S3-Compatible Storage?

S3 started as Amazon's object storage service but evolved into an industry standard. Many providers now implement the same API, making them interchangeable. I went with Backblaze B2: $0.006/GB/month storage, one of the cheapest options available, fully S3 compatible, and simple to configure.

Setting Up Backblaze B2

Step 1 — Create an Account

Register at backblaze.com and enable B2 Cloud Storage separately from their backup product.

Step 2 — Create a Bucket

Go to B2 Cloud Storage → Buckets → Create Bucket. Configure it as Private, with Default Encryption enabled.

Step 3 — Generate an Application Key

Create an application key with Read and Write access to the bucket. Save the Key ID and Application Key immediately — Backblaze only shows the Application Key once.

Step 4 — Configure CORS

CORS configuration lets mobile apps upload directly to Backblaze. Install the Backblaze CLI:

pip3 install b2 --break-system-packages
b2 authorize-account [YOUR_KEY_ID] [YOUR_APP_KEY]

Save this CORS configuration as cors.json:

[{
  "corsRuleName": "entephotos",
  "allowedOrigins": ["*"],
  "allowedHeaders": ["*"],
  "allowedOperations": [
    "b2_download_file_by_id",
    "b2_download_file_by_name",
    "b2_upload_file",
    "b2_upload_part",
    "s3_get",
    "s3_post",
    "s3_put",
    "s3_head"
  ],
  "exposeHeaders": ["X-Amz-Request-Id", "X-Amz-Id-2", "ETag"],
  "maxAgeSeconds": 3600
}]

Apply it with:

b2 bucket update --cors-rules "$(cat cors.json)" [BUCKET_NAME] allPrivate

Deploying Ente Photos via Coolify

Step 1 — Generate Encryption Keys

Run this on the server:

openssl rand -base64 32 && openssl rand -base64 64 && openssl rand -base64 32

The output maps to three environment variables:

Step 2 — Create the Service

In Coolify, choose New Resource → Docker Compose Empty and use this compose configuration:

services:
  museum:
    image: ghcr.io/ente-io/server:latest
    ports:
      - "8080"
    dns:
      - 8.8.8.8
      - 1.1.1.1
    environment:
      - ENTE_DB_HOST=postgres
      - ENTE_DB_PORT=5432
      - ENTE_DB_NAME=ente_db
      - ENTE_DB_USER=${POSTGRES_USER:-ente}
      - ENTE_DB_PASSWORD=${POSTGRES_PASSWORD:-ente_pass}
      - ENTE_HTTP_USE_TLS=false
      - ENTE_S3_ARE_LOCAL_BUCKETS=false
      - ENTE_S3_USE_PATH_STYLE_URLS=true
      - ENTE_S3_B2_EU_CEN_KEY=[YOUR_KEY_ID]
      - ENTE_S3_B2_EU_CEN_SECRET=[YOUR_APP_KEY]
      - ENTE_S3_B2_EU_CEN_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
      - ENTE_S3_B2_EU_CEN_REGION=[BUCKET_REGION]
      - ENTE_S3_B2_EU_CEN_BUCKET=[BUCKET_NAME]
      - ENTE_KEY_ENCRYPTION=[ENTE_KEY_ENCRYPTION]
      - ENTE_KEY_HASH=[ENTE_KEY_HASH]
      - ENTE_JWT_SECRET=[ENTE_JWT_SECRET]
      - ENTE_INTERNAL_DISABLE_REGISTRATION=false
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://127.0.0.1:8080/ping"]
      interval: 30s
      timeout: 10s
      retries: 3
    volumes:
      - museum-data:/data

  web:
    image: ghcr.io/ente-io/web:latest
    ports:
      - "3000"
    expose:
      - "3000"
    environment:
      - ENTE_API_ORIGIN=https://museum.yourdomain.com

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=${POSTGRES_USER:-ente}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-ente_pass}
      - POSTGRES_DB=ente_db
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ente -d ente_db"]
      interval: 10s
      timeout: 5s
      retries: 5

Step 3 — Deploy

Save and Deploy. Coolify pulls the containers and orchestrates them.

Step 4 — Set Domains

Configure container-specific domains under Settings:

Coolify provisions SSL automatically.

Step 5 — Configure DNS

Set up a wildcard DNS record:

*.yourdomain.com → YOUR_SERVER_IP

Step 6 — Create Accounts

Visit https://photos.yourdomain.com and register. Retrieve OTP verification codes from the Museum container logs:

docker logs $(docker ps | grep ente-io/server | awk '{print $1}') 2>&1 | grep "Verification code"

Step 7 — Set Storage Quota

Connect to the Postgres container:

docker exec -it $(docker ps | grep $(docker ps --format "{{.Names}}" | grep museum | sed 's/museum-//') | grep postgre | awk '{print $1}') psql -U ente -d ente_db

Update the quota (example: 100GB in bytes):

UPDATE subscriptions SET storage = 107374182400;

Step 8 — Disable Registration

Change the compose environment variable:

- ENTE_INTERNAL_DISABLE_REGISTRATION=true

Save and restart to prevent unauthorized account creation.

Connecting Your Phone

Install the official Ente Photos app. Tap the Ente logo seven times on the onboarding screen to reveal a custom server URL field, then enter:

https://museum.yourdomain.com

The Result

What's Next

The series continues with file storage, followed by disaster recovery — building out a complete private cloud on a single server for minimal monthly cost.


Pricing reflects accuracy at time of publication and is subject to change. Self-hosting requires ongoing responsibility for updates, security, and backups. I have no affiliation with any of the tools mentioned.