Analytics cookies only (Google Analytics, PostHog). No ad networks, no cross-site tracking. See our privacy policy.
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host Ente, the end-to-end encrypted photo storage that replaces Google Photos, on a VPS with the official Docker quickstart reworked for the public internet, Caddy HTTPS on four hostnames, object storage phones can actually reach, and backups that cover the database and the photos.
Ente is a photo and video storage service with one design decision that everything else follows from: files, and their metadata, are end-to-end encrypted on your device before they are uploaded. The server holds ciphertext. It never sees a photo, which means it cannot index your faces or search your library; that work happens on your phone or computer. It is licensed AGPL-3.0, written in Go (the server is called museum) with PostgreSQL, and rated 3 / 5 to deploy. If you are replacing a hosted library, the closest hosted product is Google Photos, and the closest self-hosted rival is Immich; the trade-off between the two is covered in Immich vs Ente.
Self-hosting Ente means running four things: museum, the API server; a web container with the Photos web app and the public-albums viewer; PostgreSQL, which stores account data and the encrypted keys for each file; and an S3-compatible object store, which holds the encrypted files. The official quickstart bundles all four with Docker Compose and generates the credentials for you. That quickstart is built for trying Ente on one machine, though. Put it on a VPS unchanged and you end up with plain-HTTP ports on the open internet and photo uploads that fail silently from every device except the server itself. This guide takes the quickstart and fixes exactly those problems. For picking a provider and tier, see Best VPS for self-hosting.
Ente's documentation gives 1 GB of RAM and one CPU core as the minimum for the quickstart cluster, and says the server is a lightweight Go binary because the heavy work (encryption, thumbnails, machine learning) happens on the client. In our verification run, with no photos uploaded, museum used about 30 MB, PostgreSQL about 55 MB, the object store about 80 MB, the web container about 8 MB and Caddy about 15 MB, roughly 190 MB together. The small RAM requirement is real; the real cost of running Ente is disk.
Every photo and video is stored on your server, encrypted, at its full size. Plan storage for the library you have today plus years of growth, and put the object store on a volume you can expand. A 1 GB server with a 2 TB disk is a reasonable Ente host; a 16 GB server with a 100 GB disk is not. Also keep a plaintext copy of your photos somewhere else until you have tested a restore; Ente's own documentation gives the same advice.
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you're logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
That firewall will not protect Ente by itself. On a Docker host a published
container port goes around ufw entirely, and the stock quickstart publishes
four ports to every interface. The install section below rebinds them to
127.0.0.1 and checks that it worked.
Paid link — we earn a commission if you shop through it.
If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version --short
Ente needs Docker Compose 2.30 or newer, which the quickstart script
checks for. The group change applies on your next login; until then the
commands below use sudo docker.
The quickstart script creates a my-ente directory containing a compose.yaml
and a museum.yaml with freshly generated database and storage credentials. It
asks whether to start the cluster; the printf 'n\n' answers no, because we are
going to change the generated files before the first start:
cd ~
printf 'n\n' | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ente/ente/main/server/quickstart.sh)"
ls -l my-ente
Read the script before you run it if you like; it is short, and everything it
does is write those two files. Keep museum.yaml safe from here on. It holds
the instance's credentials, and without it Ente cannot use the data stored in
the volumes.
Four edits turn the quickstart into something fit for a public server. Set your
domain first. This guide uses example.com; replace it with yours, and the
four hostnames below become photos., api., albums. and files. under it:
cd ~/my-ente
echo "BASE=example.com" > .env
grep BASE .env
1. Pin the images. The quickstart uses unpinned images, so the next
docker compose pull can move you to a different release without you asking.
These are the digests of the images we tested. Pinning by digest means an
upgrade is a deliberate edit:
cd ~/my-ente
sed -i \
-e 's|image: ghcr.io/ente/server$|image: ghcr.io/ente/server@sha256:3511ff25db3bf950abeb3983928eeadeed7f53b690ee94704c14be72fa719b1d|' \
-e 's|image: ghcr.io/ente/web$|image: ghcr.io/ente/web@sha256:b043ffe32d612f7def09268d13c688e524ed4edd019406a3368b30c38b7e9e5a|' \
-e 's|image: pgsty/silo$|image: pgsty/silo@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46|' \
compose.yaml
grep -n 'image:' compose.yaml
2. Bind the published ports to loopback and tell the web app where the API is. Only Caddy should be reachable from outside:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
sed -i \
-e 's|^\(\s*- \)\(8080:8080\) # API|\1127.0.0.1:\2|' \
-e 's|^\(\s*- \)\(3000:3000\) # Photos web app|\1127.0.0.1:\2|' \
-e 's|^\(\s*- \)\(3002:3002\) # Public albums|\1127.0.0.1:\2|' \
-e 's|^\(\s*- \)\(3200:3200\) # MinIO API|\1127.0.0.1:\2|' \
-e "s|ENTE_API_ORIGIN: .*|ENTE_API_ORIGIN: https://api.$BASE|" \
compose.yaml
grep -nE '127.0.0.1|ENTE_API_ORIGIN' compose.yaml
3. Make the object store reachable at a real address. This is the edit
that most self-hosted installs miss. Museum does not move your photos itself.
It hands the client a pre-signed URL that points straight at the object store,
and the client uploads to it. The quickstart writes endpoint: localhost:3200
into that URL, which works for a browser on the same machine and is wrong for
everyone else, because localhost on a phone is the phone. Point it at a public
hostname with HTTPS, and turn off the local-bucket mode that assumes plain
HTTP. The same file also names the public-albums address:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
sed -i \
-e 's|^\(\s*\)are_local_buckets: true|\1are_local_buckets: false|' \
-e "s|endpoint: localhost:3200|endpoint: files.$BASE|" \
-e "s|public-albums: .*|public-albums: https://albums.$BASE|" \
museum.yaml
grep -nE '^\s*are_local_buckets|endpoint:|public-albums' museum.yaml
The three bucket names (b2-eu-cen, wasabi-eu-central-2-v3 and
scw-eu-fr-v3) are fixed labels in Ente's configuration and have nothing to do
with Backblaze, Wasabi or Scaleway. Only the first one is used unless you enable
replication, which needs all three to point at separate storage.
4. Add Caddy. Compose loads docker-compose.override.yml next to the main
file, so the generated compose.yaml stays close to what the quickstart wrote:
cd ~/my-ente
cat > docker-compose.override.yml <<'EOF'
services:
caddy:
image: caddy:2
restart: always
ports: ["80:80", "443:443"]
environment:
BASE: ${BASE}
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
depends_on: [museum, web, minio]
volumes:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
api.{$BASE} {
reverse_proxy museum:8080
}
photos.{$BASE} {
reverse_proxy web:3000
}
albums.{$BASE} {
reverse_proxy web:3002
}
files.{$BASE} {
reverse_proxy minio:3200
}
EOF
sudo docker compose config --quiet && echo "compose config valid"
Caddy passes the original Host header through to the object store, which
matters: the pre-signed URLs are signed for the files. hostname, and a proxy
that rewrote the host would invalidate every signature. Before you start,
create four A records (photos, api, albums and files) pointing at the
server's public IP, and confirm they resolve:
for h in photos api albums files; do dig +short $h.example.com; done
Start the cluster and wait for museum to report healthy. The loop is bounded so a broken install fails instead of hanging:
cd ~/my-ente
sudo docker compose up -d
state=
for i in $(seq 1 60); do
state=$(sudo docker inspect --format '{{.State.Health.Status}}' my-ente-museum-1 2>/dev/null)
[ "$state" = "healthy" ] && break
sleep 3
done
echo "museum health: $state"
sudo docker compose ps --format 'table {{.Service}}\t{{.Status}}'
Caddy is already running. It requests a certificate for each of the four names as soon as they resolve and renews them on its own (see Automatic HTTPS with Caddy for how that works). Check each hostname, retrying while the certificates are issued:
BASE=$(grep '^BASE=' ~/my-ente/.env | cut -d= -f2)
bad=0
for h in api photos albums files; do
out=000
for i in $(seq 1 30); do
out=$(curl -s -o /dev/null -w '%{http_code} %{ssl_verify_result}' "https://$h.$BASE/") && [ "${out%% *}" != "000" ] && break
sleep 4
done
echo "$h.$BASE -> $out"
[ "${out##* }" = "0" ] || bad=1
done
curl -s "https://api.$BASE/ping"; echo
[ "$bad" = "0" ]
Every line should end in a certificate result of 0. The status codes differ
by design: the web apps answer 200, the API answers 404 on its root path
because it has no page there (the /ping line is the real health check), and
the object store answers 403 because an unauthenticated request to a bucket
root is refused. A 403 from files. is the healthy answer.
Now prove that the ports the quickstart publishes are no longer open to the internet. This lists every listening socket on those four ports and fails if any is bound to a public address:
sudo ss -tlnH | awk '{print $4}' | grep -E ':(8080|3000|3002|3200)$'
! sudo ss -tlnH | awk '{print $4}' | grep -E '^(0\.0\.0\.0|\[::\]|\*):(8080|3000|3002|3200)$'
All four should show 127.0.0.1. The first time we ran this the albums port
was still on 0.0.0.0, because the sed pattern for it did not match the
comment in the generated file (# Public albums, not # Public albums app).
That is the reason for the check: a wrong pattern changes nothing and says
nothing.
The page loading does not prove uploads will. Three things have to be true, and each can be tested from the server. First, museum has to reach the object store at its public address, because it verifies each upload there:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
sudo docker compose exec -T museum wget -S --spider -q "https://files.$BASE/" 2>&1 | head -2
A 403 Forbidden is the right answer: museum reached the store over HTTPS and
was refused only because it sent no credentials. Second, a real signed request
has to survive the proxy. This writes, reads and deletes a small object through
the public files. address using the object store's own client and the
credentials from museum.yaml:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
KEY=$(awk '/ key: minio/{print $2; exit}' museum.yaml)
SEC=$(awk '/ secret:/{print $2; exit}' museum.yaml)
sudo docker compose exec -T minio sh -c "mc alias set pub https://files.$BASE '$KEY' '$SEC' >/dev/null \
&& echo guide-check | mc pipe pub/b2-eu-cen/guide-check.txt >/dev/null \
&& mc cat pub/b2-eu-cen/guide-check.txt \
&& mc rm pub/b2-eu-cen/guide-check.txt >/dev/null && echo removed"
You should see guide-check and then removed. Third, the browser web app is
on a different hostname from the object store, so the store must answer the
browser's cross-origin preflight. Send one the way a browser would:
BASE=$(grep '^BASE=' ~/my-ente/.env | cut -d= -f2)
curl -s -i -X OPTIONS -H "Origin: https://photos.$BASE" \
-H 'Access-Control-Request-Method: PUT' -H 'Access-Control-Request-Headers: content-md5' \
"https://files.$BASE/b2-eu-cen/x" | grep -iE '^HTTP|access-control-allow-(origin|methods)'
Expect 204 and an access-control-allow-origin for your photos hostname.
In our run all three passed. We did not upload a real photo from a phone, which
is the test that matters most, and which you should do before trusting this
with a library.
On a fresh server the first account to register is automatically the
administrator, and the registration page is open to anyone who finds the
address. Register your own account right away, before you share the URL. Open
https://photos.example.com, choose Don't have an account?, and follow the
prompts.
Ente asks for an email address and then a verification code. Because the server has no email provider configured, it does not send the message. It writes the code to its log instead. You can see the mechanism without a browser by requesting a code for a throwaway address and reading it back:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
curl -s -o /dev/null -w 'request code: HTTP %{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"email":"ente-probe@example.net","purpose":"signup"}' "https://api.$BASE/users/ott"
sudo docker compose logs museum 2>&1 | grep 'Verification code' | tail -1 | sed 's/.*Skipping/Skipping/'
For your own sign-up, run the log command after you submit your email in the
browser, and type the six-digit code it prints. That is fine for a handful of
people; for more, either configure SMTP in museum.yaml or set fixed codes for
named addresses, both covered in Ente's user-management documentation.
Creating an account on your server creates a separate account, even with the same email address as an Ente-hosted one. It has its own password and its own recovery key, and nothing moves over. Store the recovery key in a password manager under a label that names this server; it is the only way back in if you forget the password, because the server cannot decrypt anything for you.
Once you and the people who should have accounts have registered, close the
door. Registration is disabled with one setting in museum.yaml:
cd ~/my-ente
BASE=$(grep '^BASE=' .env | cut -d= -f2)
printf '\ninternal:\n disable-registration: true\n' >> museum.yaml
sudo docker compose up -d --force-recreate museum
for i in $(seq 1 30); do curl -sf "https://api.$BASE/ping" >/dev/null && break; sleep 2; done
curl -s -o /dev/null -w 'new sign-up attempt: HTTP %{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"email":"late@example.net","purpose":"signup"}' "https://api.$BASE/users/ott"
A 403 confirms new sign-ups are refused; existing accounts keep working. To
let someone new in later, remove those two lines and recreate museum.
Last, point the mobile and desktop apps at your server. On the apps' first
screen, tap the onboarding screen seven times to open Developer settings, and enter
https://api.example.com as the server endpoint. Ente's documentation also
describes whitelisting administrators by user ID in museum.yaml, which is
worth doing before you add other people.
A working Ente needs three things together, and losing any one loses the
library: the database, the object storage volume, and museum.yaml.
Ente's own backup notes stress the one people forget. The encrypted files are
useless without the database, because the database holds each file's own
encryption key, so a careful backup of the photos with a careless one of
PostgreSQL restores nothing. These archives hold your encrypted library and its
keys, so they are created readable by their owner only. Start with the database:
cd ~/my-ente
( umask 077; sudo docker compose exec -T postgres pg_dump -U pguser -Fc ente_db > ~/ente-db-$(date +%F).dump )
test -s ~/ente-db-$(date +%F).dump && ls -lh ~/ente-db-*.dump
Then archive the object storage volume and the configuration:
cd ~/my-ente
VOL=$(sudo docker volume ls -q | grep -E 'minio-data$' | head -1)
( umask 077
sudo docker run --rm -v "$VOL":/data:ro alpine tar czf - -C /data . > ~/ente-objects-$(date +%F).tar.gz
tar czf ~/ente-config-$(date +%F).tar.gz compose.yaml museum.yaml docker-compose.override.yml Caddyfile .env )
ls -lh ~/ente-objects-*.tar.gz ~/ente-config-*.tar.gz
Copy these files off the server and run them from cron; for a large library, replace the object archive with a sync tool pointed at the volume, since a full copy every night will not scale. A backup you have never restored is a guess, so prove the database dump loads. This starts a throwaway PostgreSQL container, restores the dump into it and counts the tables:
cd ~
sudo docker rm -f ente-restore-test >/dev/null 2>&1
sudo docker run -d --name ente-restore-test -e POSTGRES_PASSWORD=scratch postgres:15 >/dev/null
for i in $(seq 1 40); do
sudo docker exec ente-restore-test pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && break
sleep 2
done
sudo docker exec ente-restore-test createdb -U postgres ente_db
sudo docker exec -i ente-restore-test pg_restore -U postgres -d ente_db --no-owner < ~/ente-db-$(date +%F).dump
n=$(sudo docker exec ente-restore-test psql -U postgres -d ente_db -At \
-c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
sudo docker rm -f ente-restore-test >/dev/null
echo "tables restored: $n"
[ "$n" -gt 10 ]
It should print a table count well above zero, and the last line fails the block
if it is ten or fewer. The check proves the dump loads and the schema is
intact; it does not start Ente against the restored data. For a real restore,
install Docker on the new server, unpack the config archive, start only
PostgreSQL, pg_restore the dump, restore the object volume, then start
everything. Do that on a scratch machine once, with a photo you recognise, before
you need it.
Because the images are pinned by digest, an upgrade is an edit. Read the release notes, take a backup (previous section), pull the current images and read their digests:
cd ~/my-ente
for i in ghcr.io/ente/server ghcr.io/ente/web; do
sudo docker pull "$i:latest" >/dev/null
sudo docker image inspect "$i:latest" --format '{{index .RepoDigests 0}}'
done
Replace the digests on the image: lines of compose.yaml with the ones
printed, then run sudo docker compose up -d. Update the mobile and desktop apps
around the same time, since a client far ahead of or behind the server can fail
to sync. Keep the postgres:15 tag fixed; a new PostgreSQL major version is a
dump-and-restore job, not a tag change.
Uploads from a phone fail, but the web app on the server worked. The
endpoint in museum.yaml still says localhost:3200, or files. is not
reachable from the phone. Museum logs OBJECT_SIZE_FETCH_FAILED with a dial
timeout when it cannot reach the address it embedded. Fix the endpoint, then
sudo docker compose up -d --force-recreate museum.
Uploads fail in the browser with a CORS error. The browser is blocked from
the files. hostname. Run the preflight check above. If it does not return an
allow-origin header, set the object store's allowed origins with its mc admin config set ... api cors_allow_origin=... command and restart it.
pq: password authentication failed when museum starts. You recreated the
my-ente directory but kept the old Docker volumes, so the database still has
the previous password. Restore the original museum.yaml, or remove the volumes
with sudo docker compose down --volumes if the install holds nothing you want.
That command deletes every uploaded photo, so be sure of the directory you run
it in.
The sign-up code never arrives. It is not sent; it is in the log. Run
sudo docker compose logs museum | grep 'Verification code'.
Caddy never gets a certificate. Confirm dig +short photos.example.com
returns the server's IP for all four names and that your provider's
network-level firewall, not just ufw, allows inbound 80 and 443. A blocked
port 80 silently breaks the ACME challenge.
You're done when all four hostnames answer over valid certificates, /ping
returns pong, the port check shows only 127.0.0.1, you have signed in on the
web and on a phone, a photo uploaded from the phone is visible on the web,
sign-ups are closed, and you have restored the database dump on a scratch box
at least once.
For the memory figures behind the sizing section, run this once the stack has settled:
sudo docker stats --no-stream --format '{{.Name}} {{.MemUsage}}'
From here, install the mobile app and turn on automatic backup, add your existing library, and read the Ente entry for catalog details. If you are still deciding between photo servers, Immich vs Ente lays out the trade. The ranked server picks are in Best VPS for self-hosting.