I self-hosted Immich — Docker refused the Compose healthcheck until Engine 25

I self-hosted Immich — Docker refused the Compose healthcheck until Engine 25

I wanted phone backups off Google Photos without another subscription. Immich looked right, until Docker refused the Compose healthcheck on my VPS — start_interval needs Engine 25. I upgraded Docker, then the stack came up.

· Updated · 3 min read #self-hosted #open-source #immich #photos #backup #privacy #media #deployment #docker #vps

 Immich private photo backup overview

Caption: Phones and browser upload to my VPS. Postgres holds the library state. Scanning a folder of JPEGs is not a restore.

Why I wanted this on my server

I wanted camera-roll backup off Google Photos. Family pictures, travel, the occasional scan — not another monthly library I do not control.

Immich is the open-source photo stack: mobile auto backup, timeline, albums, thumbnails, optional ML. It is an application with Postgres and workers, not a file browser pointed at a disk. If the database dies and I only have a folder of originals, I do not have Immich back.

What I actually installed

Ubuntu 24.04, official Immich Compose from GitHub releases, files under /opt/immich. Nginx to 127.0.0.1:2283. Media at /opt/immich/library, Postgres on local SSD at /opt/immich/postgres — not NFS. Immich wants about 6–8 GB RAM. Thumbnails and transcodes add roughly 10–20% on top of originals.

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for a Docker Engine version check.

I downloaded docker-compose.yml and example.env, filled paths and a password, ran docker compose up -d. Docker answered:

can't set healthcheck.start_interval as feature require Docker Engine v25 or later

The official Immich Compose uses start_interval on the database healthcheck. Engine 24 (and anything I got from a stale convenience install) cannot parse it.

I had two documented exits: upgrade Docker Engine to 25+, or comment that start_interval line as Immich’s install docs say. I upgraded Engine. After that, docker compose up -d started Postgres and the server.

docker compose (plugin) is required. The old docker-compose Python command is not supported.

 Immich Docker Compose stack

Caption: HTTPS at Nginx. Immich, Redis, Postgres, ML, library folder on the private side.

Prerequisites

Domain at the root of a host (immich.example.com). Sub-path /immich is unsupported. HTTPS for mobile backup. .env private. Pin IMMICH_VERSION after a known-good boot.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl wget gnupg openssl ufw nginx certbot python3-certbot-nginx gzip rsync jq

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

The working install

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

Confirm Engine is 25 or newer before you fight the healthcheck. If docker compose version fails, stop here.

2. Create the deployment directory

sudo mkdir -p /opt/immich
sudo chown -R "$USER":"$USER" /opt/immich
chmod 700 /opt/immich
cd /opt/immich

mkdir -p library postgres backups

DB_DATA_LOCATION stays on local Unix storage. Immich is explicit: network shares and Postgres are a bad mix.

3. Download the official Compose files

cd /opt/immich

wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env

chmod 600 .env

Do not commit .env to a public repo.

4. Configure .env

cd /opt/immich

export IMMICH_DB_PASSWORD="$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | head -c 32)"

cp .env ".env.$(date +%F-%H%M).bak"

python3 - <<'PY'
from pathlib import Path
import os

env_path = Path(".env")
text = env_path.read_text()
replacements = {
    "UPLOAD_LOCATION=./library": "UPLOAD_LOCATION=/opt/immich/library",
    "DB_DATA_LOCATION=./postgres": "DB_DATA_LOCATION=/opt/immich/postgres",
    "# TZ=Etc/UTC": "TZ=Etc/UTC",
    "DB_PASSWORD=postgres": f"DB_PASSWORD={os.environ['IMMICH_DB_PASSWORD']}",
}
for old, new in replacements.items():
    text = text.replace(old, new)
env_path.write_text(text)
PY

grep -E '^(UPLOAD_LOCATION|DB_DATA_LOCATION|TZ|IMMICH_VERSION|DB_USERNAME|DB_DATABASE_NAME)=' .env

The example used IMMICH_VERSION=v3 when I wrote this. Fine for a first boot. Production: pin a release tag so pull is not a surprise.

5. Start Immich

cd /opt/immich

docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=10m immich-server database redis
curl -i http://127.0.0.1:2283

If you still see can't set healthcheck.start_interval as feature require Docker Engine v25 or later, upgrade Engine or comment start_interval on the database healthcheck.

Unsafe characters in DB_PASSWORD also take the database down. I stick to the alphanumeric generation above.

6. Configure Nginx and HTTPS

Create /etc/nginx/sites-available/immich.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name immich.example.com;

    client_max_body_size 50000M;
    proxy_request_buffering off;
    client_body_buffer_size 1024k;

    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    send_timeout 600s;

    location / {
        proxy_pass http://127.0.0.1:2283;
        proxy_http_version 1.1;
        proxy_redirect off;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location = /.well-known/immich {
        proxy_pass http://127.0.0.1:2283;
    }
}

Uploads dying at Nginx: body size, proxy_request_buffering off, websocket headers. Mobile app cannot find the server: public URL, cert, DNS, /.well-known/immich.

sudo ln -s /etc/nginx/sites-available/immich.example.com /etc/nginx/sites-enabled/immich.example.com
sudo nginx -t
sudo systemctl reload nginx

sudo certbot --nginx -d immich.example.com
sudo certbot renew --dry-run

Keep in mind always

Do not rename files inside UPLOAD_LOCATION by hand. The database tracks assets. A dump without the matching library folder (or the reverse) is a half restore.

First-import slowness is thumbnails, metadata, transcode, ML — not “Immich is broken.” On 6 GB RAM I watch the ML container; if it OOMs, I turn ML down or move to a bigger VM.

cd /opt/immich

docker compose ps
docker compose logs --since=30m immich-server
docker compose logs --since=30m immich-machine-learning
docker compose exec database psql --username="${DB_USERNAME:-postgres}" --dbname="${DB_DATABASE_NAME:-immich}" -c 'select now();'
du -sh /opt/immich/library /opt/immich/postgres

Create the first admin in the browser, then normal users for phones. Enable automatic DB dumps in Administration and confirm files appear under UPLOAD_LOCATION/backups. Those dumps still do not contain photos.

First library I actually used

Ten photos and one small video in the browser. Timeline updates, thumbnails exist, files under /opt/immich/library. Then the mobile app on one album, HTTPS only, then the full roll.

 Immich storage and processing flow

Caption: Originals, Postgres rows, thumbs, encoded video. Backup all of it from the same moment.

sudo find /opt/immich/library -maxdepth 3 -type f | wc -l
du -sh /opt/immich/library
docker compose logs --since=15m immich-server immich-machine-learning

Backup, expose, next step

cd /opt/immich
mkdir -p backups

BACKUP_DATE="$(date +%F-%H%M)"

docker exec -t immich_postgres \
  pg_dump --clean --if-exists --dbname="${DB_DATABASE_NAME:-immich}" --username="${DB_USERNAME:-postgres}" \
  | gzip > "backups/immich-db-${BACKUP_DATE}.sql.gz"

rsync -aH --delete /opt/immich/library/ "backups/library-${BACKUP_DATE}/"
ls -lh backups
du -sh backups/library-${BACKUP_DATE}

Docs: dump the database first, filesystem second — or stop immich-server while you copy. Then off-box:

rsync -aH --info=progress2 /opt/immich/backups/ backup-user@backup.example.com:/srv/backups/immich/

 Immich backup checklist

Caption: Postgres dump, library folder, off-server copy, restore drill. Missing photos after restore means the two copies were not from the same time.

cd /opt/immich
docker compose pull
docker compose up -d
docker compose logs --since=10m immich-server

What I have running now is Immich on HTTPS, Engine 25, library and Postgres on local disk, one test album. Next I finish a phone backup, copy a matched dump+library off the server, and restore onto a spare VM before this is the only copy of anything I cannot re-shoot.

Did you hit the same wall?

I got stuck on can't set healthcheck.start_interval as feature require Docker Engine v25 or later. Did you hit the same thing, or a different one? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.