Skip to content

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

  1. You will need a Kubernetes cluster. I have countless guides in this website explaining different forms of provisioning one, whether managed, or self-managed.
  2. You will need a GitHub account2.
  3. 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.

ente-server/deployment.yml
---
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?

ente-server/deployment.yml
          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
ente-server/deployment.yml
      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.yaml4):

ente-server/files/museum.yaml
---
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:

ente-server/externalsecret.yml
---
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.

postgres/cluster.yml
---
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.

postgres/externalsecret-superuser.yml
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
postgres/externalsecret-user.yml
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
postgres/kustomization.yml
resources:
  - cluster.yml
  - externalsecret-superuser.yml
  - externalsecret-user.yml

namespace: ente

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-server/secrets.sh
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:

  1. /configurations/{{environment}}.yaml; environment is local by default.
  2. CLI flag of --credentials-file or /credentials.yaml by default.
  3. Lastly, the /museum.yaml file.
  4. 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:

ente-server/service.yml
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
ente-server/serviceaccount.yml
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ente-server
automountServiceAccountToken: false
ente-server/httproute.yml
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:

ente-server/kustomization.yml
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:

kubectl apply -k ente-server/

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.

Ente Docker Image
Ente Docker Image

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.

ente-docker/files/ci.yml
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
ente-docker/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
  }
  required_version = "< 2"
}
ente-docker/main.tf
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.

cd ente-docker/

tofu init -upgrade
tofu plan -out tfplan
tofu apply tfplan

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.

ente-frontend/files/ci.yml.tftpl
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
ente-frontend/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "< 6"
    }
  }
  required_version = "< 2"
}
ente-frontend/variables.tf
variable "cloudflare_zone_id" {
  type     = string
  nullable = false
}
ente-frontend/main.tf
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
}
ente-frontend/dns.tf
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

Comments