Self-host Ente Photos on Kubernetes with S3 storage

Ente Photos on K8S
Ente Photos on K8S

Introduction

Ente Photos is a self-hosted photo backup and gallery application. It has two main parts: the Ente server, also known as Museum, and the Ente web apps. Museum talks to PostgreSQL and object storage, while the web container exposes the Photos, Accounts, Albums, Auth, Cast, Locker, Embed, Paste, and Memories frontends.

In this guide, we will deploy Ente Photos on Kubernetes using PostgreSQL, S3-compatible object storage, cert-manager, Sealed Secrets, and Ingress. The manifests are based on a working Kustomize layout, but the domains, secrets, bucket names, and admin IDs are placeholders so you can adapt them safely.

Prerequisites

  1. A running Kubernetes cluster.
  2. kubectl access to the cluster.
  3. Kustomize support through kubectl apply -k or Flux.
  4. cert-manager with a ClusterIssuer named letsencrypt.
  5. Sealed Secrets controller and kubeseal.
  6. PostgreSQL database reachable from the ente namespace.
  7. S3-compatible object storage bucket for Ente uploads.
  8. DNS records for the Ente API and web hosts.

Step-by-step

  1. Create a working directory for the manifests:

    bash
    mkdir -p ente
    cd ente
    
  2. Create namespace.yaml. Keeping Ente in its own namespace makes secrets, ingresses, and application resources easier to manage:

    yaml
    ---
    apiVersion: v1
    kind: Namespace
    metadata:
      name: ente
    
  3. Create the PostgreSQL database and user. The Kubernetes manifests below expect Museum to connect to a database named ente with a user named ente:

    sql
    CREATE DATABASE ente;
    CREATE USER ente WITH PASSWORD 'change-me';
    GRANT ALL PRIVILEGES ON DATABASE ente TO ente;
    

    If you use CloudNativePG or another operator, create the database and user with that operator instead. The important part is that the final hostname, database name, username, and password match museum.yaml and credentials.yaml.

  4. Create a local credentials.yaml file. This file is mounted into the Ente server container at /credentials/credentials.yaml.

    yaml
    key:
      encryption: REPLACE_WITH_BASE64_32_BYTES
      hash: REPLACE_WITH_BASE64_64_BYTES
    jwt:
      secret: REPLACE_WITH_BASE64_32_BYTES
    smtp:
      host: smtp.example.com
      port: 465
      username: ente@example.com
      password: REPLACE_WITH_SMTP_PASSWORD
      email: ente@example.com
      sender-name: Ente
      encryption: ssl
    s3:
      b2-eu-cen:
        are_local_buckets: false
        use_path_style_urls: true
        key: REPLACE_WITH_S3_ACCESS_KEY
        secret: REPLACE_WITH_S3_SECRET_KEY
        endpoint: https://s3.example.com
        region: us-east-1
        bucket: ente
    db:
      password: REPLACE_WITH_POSTGRES_PASSWORD
    

    You can generate base64 secrets like this:

    bash
    openssl rand -base64 32
    openssl rand -base64 64
    
  5. Seal the Ente credentials. Do not commit the plain credentials.yaml file:

    bash
    kubectl -n ente create secret generic ente-credentials \
      --from-file=credentials.yaml \
      --dry-run=client -o yaml | kubeseal --format yaml > ente-cred.yaml
    

    If another manifest or database operator expects a separate PostgreSQL password Secret, create it the same way:

    bash
    export PASSWORD=REPLACE_WITH_POSTGRES_PASSWORD
    kubectl -n ente create secret generic postgres-credentials \
      --from-literal=password=$PASSWORD \
      --dry-run=client -o yaml | kubeseal --format yaml > postgres-cred.yaml
    

    In the manifests below, Museum reads the database password from credentials.yaml, so postgres-cred.yaml is optional unless you adapt the deployment to use it directly.

  6. Create cert.yaml. This example uses one wildcard certificate for the API and web hosts:

    yaml
    ---
    apiVersion: cert-manager.io/v1
    kind: Certificate
    metadata:
      name: ente
      namespace: ente
    spec:
      secretName: ente-tls
      issuerRef:
        name: letsencrypt
        kind: ClusterIssuer
      dnsNames:
        - photos.example.com
        - '*.photos.example.com'
    
  7. Create server.depl.yaml. The server container reads the credentials file from a Secret and the Museum config from a ConfigMap:

    yaml
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: ente-server
      namespace: ente
      annotations:
        reloader.stakater.com/auto: 'true'
      labels:
        app: ente-server
    spec:
      replicas: 1
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 1
          maxSurge: 0
      selector:
        matchLabels:
          app: ente-server
      template:
        metadata:
          labels:
            app: ente-server
        spec:
          containers:
            - name: ente-server
              image: ghcr.io/ente/server:0137a0c754ac0fe4f2c4c7421727c349327eb990
              ports:
                - containerPort: 8080
              env:
                - name: ENTE_CREDENTIALS_FILE
                  value: /credentials/credentials.yaml
                - name: ENTE_MUSEUM_CONFIG_FILE
                  value: /museum.yaml
              volumeMounts:
                - name: ente-server-conf
                  mountPath: /museum.yaml
                  subPath: museum.yaml
                  readOnly: true
                - name: ente-credentials
                  mountPath: /credentials
                  readOnly: true
          volumes:
            - name: ente-server-conf
              configMap:
                name: ente-server-conf
                items:
                  - key: museum.yaml
                    path: museum.yaml
            - name: ente-credentials
              secret:
                secretName: ente-credentials
                items:
                  - key: credentials.yaml
                    path: credentials.yaml
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: ente-server
      namespace: ente
      labels:
        app: ente-server
    spec:
      type: ClusterIP
      selector:
        app: ente-server
      ports:
        - name: http
          port: 8080
          targetPort: 8080
    ---
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: ente-server-conf
      namespace: ente
    data:
      museum.yaml: |
        db:
          host: "cluster-rw.postgresql"
          port: 5432
          name: "ente"
          user: "ente"
    
        apps:
          public-albums: "https://albums.photos.example.com"
          embed-albums: "https://embed.photos.example.com"
          public-locker: "https://locker.photos.example.com"
          public-paste: "https://paste.photos.example.com"
          cast: "https://cast.photos.example.com"
          accounts: "https://accounts.photos.example.com"
          public-memories: "https://memories.photos.example.com"
    
        space:
          assets:
            primaryBucket: b2-eu-cen
    
        internal:
          disable-registration: false
          admins: []
    
  8. Create web.depl.yaml. The Ente web image exposes several frontends from one container. Each frontend gets a named Service port:

    yaml
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: ente-web
      namespace: ente
      annotations:
        reloader.stakater.com/auto: 'true'
      labels:
        app: ente-web
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: ente-web
      template:
        metadata:
          labels:
            app: ente-web
        spec:
          containers:
            - name: ente-web
              image: ghcr.io/ente/web:062c7066883b37978b6dea7b7703d4efc5f13351
              ports:
                - containerPort: 3000
                - containerPort: 3001
                - containerPort: 3002
                - containerPort: 3003
                - containerPort: 3004
                - containerPort: 3005
                - containerPort: 3006
                - containerPort: 3008
                - containerPort: 3009
                - containerPort: 3010
              env:
                - name: ENTE_API_ORIGIN
                  value: https://api.photos.example.com
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: ente-web
      namespace: ente
      labels:
        app: ente-web
    spec:
      type: ClusterIP
      selector:
        app: ente-web
      ports:
        - name: photos
          port: 3000
          targetPort: 3000
        - name: accounts
          port: 3001
          targetPort: 3001
        - name: albums
          port: 3002
          targetPort: 3002
        - name: auth
          port: 3003
          targetPort: 3003
        - name: cast
          port: 3004
          targetPort: 3004
        - name: public-locker
          port: 3005
          targetPort: 3005
        - name: embed
          port: 3006
          targetPort: 3006
        - name: paste
          port: 3008
          targetPort: 3008
        - name: locker
          port: 3009
          targetPort: 3009
        - name: memories
          port: 3010
          targetPort: 3010
    
  9. Create ingress.yaml. The API host points to ente-server, and the web hosts point to the matching named ports on ente-web:

    yaml
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: ente-server
      namespace: ente
    spec:
      tls:
        - hosts:
            - api.photos.example.com
          secretName: ente-tls
      rules:
        - host: api.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-server
                    port:
                      number: 8080
    ---
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: ente-web
      namespace: ente
    spec:
      tls:
        - hosts:
            - photos.example.com
            - accounts.photos.example.com
            - albums.photos.example.com
            - auth.photos.example.com
            - cast.photos.example.com
            - locker.photos.example.com
            - embed.photos.example.com
            - paste.photos.example.com
            - memories.photos.example.com
          secretName: ente-tls
      rules:
        - host: photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: photos
        - host: accounts.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: accounts
        - host: albums.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: albums
        - host: auth.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: auth
        - host: cast.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: cast
        - host: locker.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: public-locker
        - host: embed.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: embed
        - host: paste.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: paste
        - host: memories.photos.example.com
          http:
            paths:
              - path: /
                pathType: Prefix
                backend:
                  service:
                    name: ente-web
                    port:
                      name: memories
    
  10. Create kustomization.yaml:

    yaml
    apiVersion: kustomize.config.k8s.io/v1beta1
    kind: Kustomization
    resources:
      - namespace.yaml
      - cert.yaml
      - ente-cred.yaml
      - ingress.yaml
      - web.depl.yaml
      - server.depl.yaml
    

    The plain credentials.yaml file is only an input for kubeseal. Do not add it to kustomization.yaml.

  11. Deploy Ente Photos:

    bash
    kubectl apply -k .
    
  12. Verify the rollout:

    bash
    kubectl -n ente get pods
    kubectl -n ente get svc
    kubectl -n ente get ingress
    kubectl -n ente get certificate
    

    Check the server logs if the API does not start:

    bash
    kubectl -n ente logs deploy/ente-server
    
  13. Create your first user from the web UI, then add the user ID as an admin if you want to keep registration disabled. Query users from PostgreSQL:

    sql
    SELECT id, email FROM users;
    

    Then update the internal.admins list in museum.yaml:

    yaml
    internal:
      disable-registration: true
      admins:
        - 1234567890123456
    

    Apply the updated ConfigMap:

    bash
    kubectl apply -k .
    kubectl -n ente rollout restart deploy/ente-server
    
  14. Remove the default 10 GB subscription quota. On a self-hosted Ente instance, storage quota and account validity can be increased with the Ente CLI. First, make sure the admin user's ID is listed in internal.admins in museum.yaml, then restart Museum:

    yaml
    internal:
      disable-registration: true
      admins:
        - 1234567890123456
    
    bash
    kubectl apply -k .
    kubectl -n ente rollout restart deploy/ente-server
    

    Configure the Ente CLI to talk to your self-hosted API endpoint. The CLI reads config.yaml from ~/.ente, from ENTE_CLI_CONFIG_DIR, or from the directory where the CLI is running:

    yaml
    endpoint:
      api: https://api.photos.example.com
    

    Add the admin account to the CLI:

    bash
    ente account add
    

    Then remove the 10 GB quota for the target user:

    bash
    ente admin update-subscription \
      -a admin@example.com \
      -u user@example.com \
      --no-limit
    

    Replace admin@example.com with the whitelisted admin email, and replace user@example.com with the registered user whose quota you want to update. The target user must already exist and must have completed email verification.

    If you need to run the CLI from inside the Museum container, set the CLI secrets path before running the command:

    bash
    export ENTE_CLI_SECRETS_PATH=./secrets.txt
    

    To turn unlimited storage off later, run the same command with --no-limit False, then use ente admin update-subscription --help to choose the storage and validity values you want.

Conclusion

You now have Ente Photos running on Kubernetes with the web apps behind Ingress, Museum behind a separate API host, credentials stored as a Sealed Secret, PostgreSQL for metadata, and S3-compatible object storage for uploads.

Before using it for real photos, test registration, login, uploads, album sharing, email delivery, and object storage backups. Also keep the plain credentials.yaml out of Git and back up your PostgreSQL database and S3 bucket.

If you found this useful, you can buy me a coffee! Thanks for the support!