Ente: Self Host the Google Photos Alternative and Own Your Privacy¶
In the recent few years, I keep seeing people being more aware of their privacy and taking it into their own hands.
More and more solutions are emerging through the community that address the critical part of our society and personal life; privacy!
In this blog post, I will introduce you to Ente, the Google Photos alternative.
You will see the codes required to deploy the server into a Kubernetes setup and host the frontend using GitHub Pages.
Stick around till the end if that's your cup of tea.
Introduction¶
It delights me to see more and more people raising concerns on the matter of privacy.
It's a crucial aspect, one that is regularly overlooked due to the enormous amount of tracking (by big conglamorate) that is concerningly becoming a habit and we're becoming numb to the idea!
This shouldn't be the norm. It's not right!
And hopefully, reading this post and similar ones like this throughout the internet, you will take your privacy more seriously and start taking it under your control.
To a tracking-less and cookie-less internet . Let's begin!
Preface¶
Before we dive in, it's important to mention that this blog post, as well as the rest of the articles in this website are all technical-heavy. More importantly, we focus on Kubernetes here, cause that's what we do in our day to day life, and that's what you'd hear us talking about non-stop if you were to sit and chat with us.
If you're not technical, or do not enjoy the ecosystem of Kubernetes, then probably this blog post is not for you. The official Ente documentation has a proper guide1 on docker compose deployment which tends to be a lot easier for non-technical people.
With that out of the way, let us not waste any more time.
Prerequisites¶
- You will need a Kubernetes cluster. I have countless guides in this website explaining different forms of provisioning one, whether managed, or self-managed.
- You will need a GitHub account2.
- Understanding and the installation of OpenTofu (or Terraform)3.
Ente Server Kubernetes Deployment¶
When you have your basic tools setup, it's time to provide you with the codes necessary to deploy the Ente server in a Kubernetes deployment.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ente-server
spec:
progressDeadlineSeconds: 120
replicas: 1
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
type: RollingUpdate
template:
spec:
containers:
- image: ghcr.io/ente-io/server
livenessProbe:
failureThreshold: 3
httpGet:
path: /ping
port: http
initialDelaySeconds: 10
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 2
name: ente-server
ports:
- containerPort: 8080
name: http
- containerPort: 2112
name: metrics
readinessProbe:
failureThreshold: 3
httpGet:
path: /ping
port: http
initialDelaySeconds: 10
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 2
resources: {}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsGroup: 65534
runAsNonRoot: true
runAsUser: 65534
volumeMounts:
- name: ente-server-credentials
mountPath: /credentials.yaml
readOnly: true
subPath: credentials.yaml
- name: ente-server-museum
mountPath: /museum.yaml
readOnly: true
subPath: museum.yaml
- name: tmp
mountPath: /tmp
securityContext:
fsGroup: 65534
fsGroupChangePolicy: Always
seccompProfile:
type: RuntimeDefault
supplementalGroups: []
sysctls: []
serviceAccountName: ente-server
terminationGracePeriodSeconds: 10
volumes:
- name: ente-server-credentials
secret:
defaultMode: 0400
secretName: ente-server-credentials
optional: false
- name: ente-server-museum
configMap:
defaultMode: 0400
name: ente-server-museum
optional: false
- name: tmp
emptyDir: {}
Noticed the volumes section of the container above?
volumeMounts:
- name: ente-server-credentials
mountPath: /credentials.yaml
readOnly: true
subPath: credentials.yaml
- name: ente-server-museum
mountPath: /museum.yaml
readOnly: true
subPath: museum.yaml
volumes:
- name: ente-server-credentials
secret:
defaultMode: 0400
secretName: ente-server-credentials
optional: false
- name: ente-server-museum
configMap:
defaultMode: 0400
name: ente-server-museum
optional: false
There is one secret reference and another configMap.
Here's the config file (the museum.yaml
4):
---
db:
host: postgres-rw.ente
port: 5432
name: ente
sslmode: disable
s3:
are_local_buckets: false
# wasabi
b2-eu-cen:
bucket: ente-photos-eu-cen
endpoint: https://s3.eu-central-2.wasabisys.com:443
region: eu-central-2
use_path_style_urls: true
hot_storage:
primary: b2-eu-cen
smtp:
email: ente@mailing.developer-friendly.blog
host: smtp.postmarkapp.com
port: 587
sender-name: Developer Friendly
webauthn:
rpid: developer-friendly.blog
rporigins:
- https://accounts.developer-friendly.blog
internal:
silent: false
# TODO: Add your own user ID here after the first registration
# admins:
# - 1234567812345678
disable-registration: false
jobs:
cron:
skip: false
remove-unreported-objects:
worker-count: 4
clear-orphan-objects:
enabled: true
apps:
public-albums: https://photos.developer-friendly.blog
cast: https://cast.developer-friendly.blog
accounts: https://accounts.developer-friendly.blog
family: https://auth.developer-friendly.blog
And the secret:
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: ente-server-credentials
spec:
data:
- remoteRef:
key: /ente/db/password
secretKey: ENTE_DB_PASSWORD
- remoteRef:
key: /ente/db/user
secretKey: ENTE_DB_USER
- remoteRef:
key: /ente/jwt/secret
secretKey: ENTE_JWT_SECRET
- remoteRef:
key: /ente/key/encryption
secretKey: ENTE_KEY_ENCRYPTION
- remoteRef:
key: /ente/key/hash
secretKey: ENTE_KEY_HASH
- remoteRef:
key: /ente/s3/b2-eu-cen/key
secretKey: ENTE_S3_B2_EU_CEN_KEY
- remoteRef:
key: /ente/s3/b2-eu-cen/secret
secretKey: ENTE_S3_B2_EU_CEN_SECRET
- remoteRef:
key: /ente/smtp/password
secretKey: ENTE_SMTP_PASSWORD
- remoteRef:
key: /ente/smtp/username
secretKey: ENTE_SMTP_USERNAME
refreshInterval: 24h
secretStoreRef:
kind: ClusterSecretStore
name: aws-parameter-store
target:
template:
data:
credentials.yaml: |
db:
password: "{{ .ENTE_DB_PASSWORD | toString }}"
user: "{{ .ENTE_DB_USER | toString }}"
jwt:
secret: "{{ .ENTE_JWT_SECRET | toString }}"
key:
encryption: "{{ .ENTE_KEY_ENCRYPTION | toString }}"
hash: "{{ .ENTE_KEY_HASH | toString }}"
s3:
b2-eu-cen:
key: "{{ .ENTE_S3_B2_EU_CEN_KEY | toString }}"
secret: "{{ .ENTE_S3_B2_EU_CEN_SECRET | toString }}"
smtp:
password: "{{ .ENTE_SMTP_PASSWORD | toString }}"
username: "{{ .ENTE_SMTP_USERNAME | toString }}"
The External Secrets is fetching the target secrets from AWS Parameter Store.
Postgres Database
The database used for this setup is coming from Cloudnative-PG5.
They have an awesome production-ready setup for Postgres which you can deploy to any Kubernetes cluster out-of-the-box.
Here's the manifests used for this deployment.
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: postgres
spec:
bootstrap:
initdb:
database: ente
owner: ente
secret:
name: postgres-user
enableSuperuserAccess: true
failoverDelay: 120
instances: 1
monitoring:
enablePodMonitor: true
postgresql:
parameters:
checkpoint_completion_target: "0.9"
default_statistics_target: "100"
effective_cache_size: 3GB
effective_io_concurrency: "200"
huge_pages: off
maintenance_work_mem: 256MB
max_connections: "100"
max_wal_size: 4GB
min_wal_size: 1GB
random_page_cost: "1.1"
shared_buffers: 1GB
wal_buffers: 16MB
work_mem: 5242kB
storage:
size: 10Gi
superuserSecret:
name: postgres-superuser
Get your PGtune config from the available online website6.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: postgres-superuser
spec:
data:
- remoteRef:
key: /postgres/ente/superuser/username
secretKey: username
- remoteRef:
key: /postgres/ente/superuser/password
secretKey: password
refreshInterval: 24h
secretStoreRef:
kind: ClusterSecretStore
name: aws-parameter-store
target:
template:
type: kubernetes.io/basic-auth
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: postgres-user
spec:
data:
- remoteRef:
key: /ente/db/user
secretKey: username
- remoteRef:
key: /ente/db/password
secretKey: password
refreshInterval: 24h
secretStoreRef:
kind: ClusterSecretStore
name: aws-parameter-store
target:
template:
type: kubernetes.io/basic-auth
I have another guide dedicated to how to authenticate ESO to the AWS API.
And chances are, you will most likely need to pull your secrets from different vendors, so I won't go into details here.
But to put those secrets in AWS SSM, here's the sequence of commands you need to run:
ENTE_DB_USER=CHANGEME
ENTE_DB_PASSWORD=CHANGEME
ENTE_S3_B2_EU_CEN_KEY=ACCESS_KEY
ENTE_S3_B2_EU_CEN_SECRET=SECRET_KEY
# https://github.com/ente-io/ente
# go run tools/gen-random-keys/main.go
ENTE_KEY_ENCRYPTION=CHANGEME
ENTE_KEY_HASH=CHANGEME
ENTE_JWT_SECRET=CHANGEME
ENTE_SMTP_USERNAME=CHANGEME
ENTE_SMTP_PASSWORD=CHANGEME
aws ssm put-parameter --name '/ente/db/user' --value $ENTE_DB_USER --type SecureString --overwrite
aws ssm put-parameter --name '/ente/db/password' --value $ENTE_DB_PASSWORD --type SecureString --overwrite
aws ssm put-parameter --name '/ente/s3/b2-eu-cen/key' --value $ENTE_S3_B2_EU_CEN_KEY --type SecureString --overwrite
aws ssm put-parameter --name '/ente/s3/b2-eu-cen/secret' --value $ENTE_S3_B2_EU_CEN_SECRET --type SecureString --overwrite
aws ssm put-parameter --name '/ente/key/encryption' --value "$ENTE_KEY_ENCRYPTION" --type SecureString --overwrite
aws ssm put-parameter --name '/ente/key/hash' --value "$ENTE_KEY_HASH" --type SecureString --overwrite
aws ssm put-parameter --name '/ente/jwt/secret' --value "$ENTE_JWT_SECRET" --type SecureString --overwrite
aws ssm put-parameter --name '/ente/smtp/username' --value $ENTE_SMTP_USERNAME --type SecureString --overwrite
aws ssm put-parameter --name '/ente/smtp/password' --value $ENTE_SMTP_PASSWORD --type SecureString --overwrite
Note that by passing multiple configuration files, we are overriding the values from the default local.yaml
file7.
In the (semi-official) Docker image we will manually build shortly from the original source code, the order of precedence is as follows (from lowest to highest)8:
/configurations/{{environment}}.yaml
; environment islocal
by default.- CLI flag of
--credentials-file
or/credentials.yaml
by default. - Lastly, the
/museum.yaml
file. - Environment variables starting with
ENTE_
and all caps.
After these steps, if a config is not yet valued, an error will be raised and ente server will not start.
It's time to create the remaining resources:
apiVersion: v1
kind: Service
metadata:
name: ente-server
spec:
ports:
- port: 80
protocol: TCP
targetPort: http
name: http
- port: 2112
protocol: TCP
targetPort: metrics
name: metrics
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ente-server
automountServiceAccountToken: false
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: ente-server
spec:
hostnames:
- ente.developer-friendly.blog
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cilium
namespace: cert-manager
sectionName: https
rules:
- backendRefs:
- kind: Service
name: ente-server
port: 80
filters:
- responseHeaderModifier:
set:
- name: Strict-Transport-Security
value: max-age=31536000; includeSubDomains; preload
type: ResponseHeaderModifier
matches:
- path:
type: PathPrefix
value: /
And to put all this together:
configMapGenerator:
- name: ente-server-museum
files:
- files/museum.yaml
resources:
- deployment.yml
- externalsecret.yml
- httproute.yml
- service.yml
- serviceaccount.yml
images:
- name: ghcr.io/ente-io/server
newName: ghcr.io/developer-friendly/ente-docker/ente-server
newTag: 20250222-arm64
labels:
- includeSelectors: true
pairs:
app.kubernetes.io/component: ente-server
app.kubernetes.io/instance: ente-server
app.kubernetes.io/managed-by: Kustomize
app.kubernetes.io/name: ente-server
app.kubernetes.io/part-of: ente-server
app.kubernetes.io/version: v1.0.0
namespace: ente
Applying this stack is just as simple as running the following against the Kubernetes cluster:
Building Ente Museum Docker Image¶
Now, you've seen us using a custom image repository instead of the official one.
As of this writing, the Ente repository9 does not provide consistent releases in its Docker repository10.

Therefore, we gotta build the server in-house and update it as needed.
The purpose of this section is to create a public GitHub repository that pulls the source code from the official repository, build the server and push it to the GitHub Container Registry.
name: ci
concurrency:
cancel-in-progress: true
group: ci-${{ github.ref_name }}-${{ github.event_name }}
on:
push:
branches:
- main
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
packages: write
security-events: write
id-token: write
jobs:
build-server:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
platform: linux/amd64
- runner: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
steps:
- name: Prepare image repository
id: image-repo
run: |
echo "today=$(date +%Y%m%d)" >> $GITHUB_OUTPUT
- name: Build docker
uses: meysam81/build-docker@main
with:
context: ./server
cosign: true
image-extra-tags: |
ghcr.io/${{ github.repository }}/ente-server:${{ steps.image-repo.outputs.today }}-${{ matrix.arch }}
image-name: ghcr.io/${{ github.repository }}/ente-server
kubescape: true
kubescape-upload-sarif: true
platforms: ${{ matrix.platform }}
repository: ente-io/ente
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
}
required_version = "< 2"
}
provider "github" {
owner = "developer-friendly"
}
resource "github_repository" "this" {
name = "ente-docker"
visibility = "public"
vulnerability_alerts = true
auto_init = true
}
resource "github_repository_file" "ci" {
repository = github_repository.this.name
branch = "main"
file = ".github/workflows/ci.yml"
content = file("${path.module}/files/ci.yml")
commit_message = "chore(CI): add workflow"
commit_author = "opentofu[bot]"
commit_email = "opentofu[bot]@users.noreply.github.com"
overwrite_on_create = true
}
And let's try to create this TF stack.
With this repository, we get daily updates from the latest features and enhancements in the upstream repository.
We can take this one step further; using renovate bot, we'll even get updates in our Ente server image. An exercise for the nerdy reader.
Deploy Ente Frontend to GitHub Pages¶
We have our custom-built docker image and our server up and running. It's time to deploy the frontend of the Ente to GitHub Pages so that public URLs in the browser can resolve to appealing UIs which we can open and use, even outside our Ente Desktop11 and Ente mobile app.
name: ci
concurrency:
cancel-in-progress: true
group: ci-$${{ github.ref_name }}-$${{ github.event_name }}
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: $${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
repository: ente-io/ente
ref: main
token: $${{ github.token }}
submodules: recursive
- name: Set Node.js
uses: actions/setup-node@v4
with:
node-version: latest
- name: Cache dependencies
uses: actions/cache@v4
with:
path: web/node_modules
key: $${{ runner.os }}-node-$${{ hashFiles('**/package.json') }}
restore-keys: |
$${{ runner.os }}-node-
- name: Enable corepack
run: corepack enable
- name: Yarn clean cache
uses: borales/actions-yarn@v5
with:
cmd: cache clean
dir: web
- name: Yarn install
uses: borales/actions-yarn@v5
with:
cmd: install
dir: web
- name: Yarn build
uses: borales/actions-yarn@v5
with:
cmd: build:${build_target}
dir: web
env:
NEXT_PUBLIC_ENTE_ENDPOINT: https://ente.developer-friendly.blog
NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT: https://photos.developer-friendly.blog
- name: Setup Pages
uses: actions/configure-pages@v5
- id: upload-artifact
name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: web/apps/${build_target}/out
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "< 6"
}
}
required_version = "< 2"
}
resource "github_repository" "this" {
for_each = toset([
"accounts",
"photos",
"auth",
"cast",
])
name = format("ente-%s", each.key)
visibility = "public"
vulnerability_alerts = true
auto_init = true
pages {
build_type = "workflow"
source {
branch = "main"
path = "/"
}
# `cname` is only applied after the initial repo creation
# you'll need to `tofu apply` this stack twice! :(
cname = format("%s.developer-friendly.blog", each.key)
}
}
resource "github_repository_file" "ci" {
for_each = github_repository.this
repository = each.value.name
branch = "main"
file = ".github/workflows/ci.yml"
content = templatefile("${path.module}/files/ci.yml.tftpl", {
build_target = each.key
})
commit_message = "chore(CI): add pages deployment workflow"
commit_author = "opentofu[bot]"
commit_email = "opentofu[bot]@users.noreply.github.com"
overwrite_on_create = true
}
data "cloudflare_zone" "this" {
zone_id = var.cloudflare_zone_id
}
resource "cloudflare_dns_record" "this" {
for_each = github_repository.this
zone_id = data.cloudflare_zone.this.zone_id
content = "developer-friendly.github.io"
name = each.key
proxied = false
ttl = 1
type = "CNAME"
}
Just as before, we'll be able to create and deploy this with the tofu
command.
Once these frontend codes are deployed, we'll be able to open the provided URLs, e.g. at photos.developer-friendly.blog
and start using the Ente frontend.
If you don't want to deploy the frontend, you can still use the Mobile or Desktop app.
However, there are certain things that may not be available to your self-hosted Ente due to such decision.
For example, the Passkey support is only available in Ente Accounts frontend.
If you do not deploy that code, you won't be able to create your passkey or use them in your login process.
Conclusion¶
In this blog post, you've seen how to deploy the full end-to-end encrypted Ente app, both the server code (written in Golang), as well as all the frontend codes.
You can skip the whole setup and go for the hosted version. They do have competitive pricing and it reduces a lot of hassle and management you'll have to endure otherwise.
But, if you feel nerdy, and if you like Kubernetes, then by all means, take inspiration from what you've seen here and build your setup.
I would love for you to leave a comment if you want to share your story.
That's all I had to say. Thanks for sticking around.
If you enjoyed this piece and read all the way down here, you might wanna subscribe to the newsletter or the rss feed.
Until next time, ciao & happy coding!
If you enjoyed this blog post, consider sharing it with these buttons . Please leave a comment for us at the end, we read & love 'em all.
Share on Share on Share on Share on
-
https://github.com/ente-io/ente/blob/5806eb6e608dedc3713da1ebcab22f0cb87b7b23/server/pkg/utils/config/config.go#L59 ↩
-
https://cloudnative-pg.io/documentation/current/installation_upgrade/ ↩
-
https://github.com/ente-io/ente/blob/5806eb6e608dedc3713da1ebcab22f0cb87b7b23/server/configurations/local.yaml ↩
-
https://github.com/ente-io/ente/blob/5806eb6e608dedc3713da1ebcab22f0cb87b7b23/server/pkg/utils/config/config.go#L35-L65 ↩
-
https://help.ente.io/photos/troubleshooting/desktop-install/ ↩