Self-host Ente Photos on Kubernetes with S3 storage

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
- A running Kubernetes cluster.
kubectlaccess to the cluster.- Kustomize support through
kubectl apply -kor Flux. - cert-manager with a
ClusterIssuernamedletsencrypt. - Sealed Secrets controller and
kubeseal. - PostgreSQL database reachable from the
entenamespace. - S3-compatible object storage bucket for Ente uploads.
- DNS records for the Ente API and web hosts.
Step-by-step
-
Create a working directory for the manifests:
bashmkdir -p ente cd ente -
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 -
Create the PostgreSQL database and user. The Kubernetes manifests below expect Museum to connect to a database named
entewith a user namedente:sqlCREATE 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.yamlandcredentials.yaml. -
Create a local
credentials.yamlfile. This file is mounted into the Ente server container at/credentials/credentials.yaml.yamlkey: 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_PASSWORDYou can generate base64 secrets like this:
bashopenssl rand -base64 32 openssl rand -base64 64 -
Seal the Ente credentials. Do not commit the plain
credentials.yamlfile:bashkubectl -n ente create secret generic ente-credentials \ --from-file=credentials.yaml \ --dry-run=client -o yaml | kubeseal --format yaml > ente-cred.yamlIf another manifest or database operator expects a separate PostgreSQL password Secret, create it the same way:
bashexport 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.yamlIn the manifests below, Museum reads the database password from
credentials.yaml, sopostgres-cred.yamlis optional unless you adapt the deployment to use it directly. -
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' -
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: [] -
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 -
Create
ingress.yaml. The API host points toente-server, and the web hosts point to the matching named ports onente-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 -
Create
kustomization.yaml:yamlapiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - namespace.yaml - cert.yaml - ente-cred.yaml - ingress.yaml - web.depl.yaml - server.depl.yamlThe plain
credentials.yamlfile is only an input forkubeseal. Do not add it tokustomization.yaml. -
Deploy Ente Photos:
bashkubectl apply -k . -
Verify the rollout:
bashkubectl -n ente get pods kubectl -n ente get svc kubectl -n ente get ingress kubectl -n ente get certificateCheck the server logs if the API does not start:
bashkubectl -n ente logs deploy/ente-server -
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:
sqlSELECT id, email FROM users;Then update the
internal.adminslist inmuseum.yaml:yamlinternal: disable-registration: true admins: - 1234567890123456Apply the updated ConfigMap:
bashkubectl apply -k . kubectl -n ente rollout restart deploy/ente-server -
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.adminsinmuseum.yaml, then restart Museum:yamlinternal: disable-registration: true admins: - 1234567890123456bashkubectl apply -k . kubectl -n ente rollout restart deploy/ente-serverConfigure the Ente CLI to talk to your self-hosted API endpoint. The CLI reads
config.yamlfrom~/.ente, fromENTE_CLI_CONFIG_DIR, or from the directory where the CLI is running:yamlendpoint: api: https://api.photos.example.comAdd the admin account to the CLI:
bashente account addThen remove the 10 GB quota for the target user:
bashente admin update-subscription \ -a admin@example.com \ -u user@example.com \ --no-limitReplace
admin@example.comwith the whitelisted admin email, and replaceuser@example.comwith 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:
bashexport ENTE_CLI_SECRETS_PATH=./secrets.txtTo turn unlimited storage off later, run the same command with
--no-limit False, then useente admin update-subscription --helpto 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!