# Can I self-host Pixieset?

**YES** — it's called Lychee. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $16/mo you stop paying ($192/yr on the Plus plan).

Lychee authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/pixieset/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Lychee 7.7.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point here. Say why when you ask: the hostname becomes `APP_URL`, and
Lychee builds every album link and image URL from it, so moving later breaks links already out.

Lychee and its database need 2048 MB of RAM available and 10 GB free on /srv: the app image is
PHP 8.5 under FrankenPHP with ImageMagick and ffmpeg in it, upstream caps that at 2 GB, and
MariaDB wants its own. Both images publish amd64 and arm64. Measure all five:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
id -u
dig +short <DOMAIN>
```

Under either floor, print both numbers and stop. Do not install and hope. If `dig +short` prints
nothing, print that and stop: Caddy cannot certify a name nobody resolves. If `id -u` prints
under 33, stop: the container's start-up script rejects a `PUID` outside 33 to 65534.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/lychee /srv/lychee/backups /srv/lychee/uploads /srv/lychee/logs /srv/lychee/tmp
sudo install -d -m 700 /srv/lychee/mariadb
ls -la /srv/lychee
```

Assert: `backups`, `uploads`, `logs` and `tmp` owned by the login user, `mariadb` at mode `700`
owned by root. Leave that one alone; the MariaDB image chowns its own data directory and refuses
one somebody claimed first. `uploads` is the half of this install a dump cannot rebuild, the
originals and every resized variant. `tmp` and `logs` are working space, not state.

## 3. Secrets

Three, all generated here. `APP_KEY` is the Laravel application key and the container refuses to
boot without one decoding to exactly 32 bytes. The other two are the `lychee` database user's
password and the MariaDB root password. Lychee ships no account and no admin token, so the admin
is created in the browser in step 7. Print none of the three, in chat, summary or log.

```bash
umask 077
cat > /srv/lychee/.env <<EOF
APP_URL=https://<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> /srv/lychee/.env
chmod 600 /srv/lychee/.env
umask 022
ls -l /srv/lychee/.env
```

Replace `<DOMAIN>` there with the real hostname first. Assert: mode `-rw-------`, owned by the
login user. Compose reads this file for the `${...}` substitutions in compose.yml and never
mounts it. Hex for the database passwords: upstream warns a `DB_PASSWORD` carrying punctuation
has to be quoted, and one without cannot be quoted wrong.

## 4. compose.yml

```bash
cat > /srv/lychee/compose.yml <<'EOF'
# Lychee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://lycheeorg.dev/docs/getting-started/docker/
#   compose template . https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   entrypoint ....... https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#
# Lychee plus the MariaDB holding albums, users, tags and photo metadata.
# MariaDB because upstream's README compose and the DB_CONNECTION default both
# say mysql; the image is the FrankenPHP build on the plain version tag, not a
# -legacy one. Every ${...} comes from /srv/lychee/.env, mode 600, which
# Compose reads and never mounts. Digests read from the registries on
# 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: lychee-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: lychee
      MARIADB_USER: lychee
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/lychee/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  lychee:
    image: lycheeorg/lychee:v7.7.2@sha256:daacbba4876b3b73e4d46be1f4858f43cb2256c9c506c0ab7f333a8d9c993c00
    container_name: lychee
    restart: unless-stopped
    environment:
      # No APP_KEY, no boot: the entrypoint checks it decodes to 32 bytes.
      APP_KEY: ${APP_KEY}
      # Every album link and image URL is built from APP_URL. Caddy terminates
      # TLS and speaks plain http here, so the scheme is forced, not guessed.
      APP_URL: ${APP_URL}
      APP_FORCE_HTTPS: "true"
      APP_ENV: production
      APP_DEBUG: "false"
      TIMEZONE: UTC
      DB_CONNECTION: mysql
      DB_HOST: db
      # The entrypoint waits on this port with nc, so it is never left unset.
      DB_PORT: "3306"
      DB_DATABASE: lychee
      DB_USERNAME: lychee
      DB_PASSWORD: ${DB_PASSWORD}
      # sync: the request that uploads a photo also builds its thumbnails.
      # database would queue that for a worker this file does not run, and
      # Octane cuts a request at 30s by default, that upload's real ceiling.
      QUEUE_CONNECTION: sync
      LYCHEE_MAX_EXECUTION_TIME: "180"
      # The entrypoint moves its www-data to these before dropping privileges,
      # so the login user owns the photo files.
      PUID: ${PUID}
      PGID: ${PGID}
    volumes:
      # uploads is the half of the backup a database dump cannot rebuild.
      - /srv/lychee/uploads:/app/public/uploads
      - /srv/lychee/logs:/app/storage/logs
      - /srv/lychee/tmp:/app/storage/tmp
    healthcheck:
      # /up answers 200 before any account exists, which the app root does not.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8000/up || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy on this host alone reaches 8195.
      - "127.0.0.1:8195:8000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/lychee && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. No database port is published, and no credential is written here.

## 5. Caddy and TLS

Append the block below, with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-lychee
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Lychee · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://lycheeorg.dev/docs/getting-started/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and every album link Lychee prints is built from it.

<DOMAIN> {
	encode zstd gzip

	# Lychee sends X-Content-Type-Options and Referrer-Policy itself. This
	# block adds only what belongs to whatever terminates TLS: HSTS, which
	# Lychee leaves off because it cannot know it is behind a certificate.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		-Server
	}

	# 8195 is the loopback port compose publishes here, not a container port
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8195
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-lychee, reload, and
report the objection. Caddy gets the certificate on first request and renews it.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a Prompt Zero box:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8195 is on loopback and 3306 is never published, so neither has a host port to firewall.
Assert: `Status: active`, rules for 80, 443/tcp and 443/udp, nothing else.

## 7. Start and verify

The first start migrates the database and caches config, routes and views. Read this block to the
end first: there is a race in the middle of it.

```bash
cd /srv/lychee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/up | grep -c 'Lychee is up'
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/install/admin | grep -c 'Set up admin account'
```

Assert all four, printing what you received. The loop ends on `200`. The grep prints above `0`,
`Lychee is up` being the heading the health page renders. The third prints
`307 https://<DOMAIN>/install/admin`, Lychee sending every page to the setup form until an
administrator exists. The last prints `1`, the form itself. On any miss, stop and run
`docker compose logs --tail 40 lychee` and `--tail 20 db`: a database never reporting healthy is
step 2, `APP_KEY is not set` is step 3, `502` is step 5. A running container is not success.

That `307` is the security problem here, and it has a clock on it. The setup form is
unauthenticated by necessity, there being no account yet to authenticate against, and it stays
open to whoever reaches the hostname until somebody submits it. First to submit owns this
gallery. The image carries a create-admin helper reading `ADMIN_USER`, and the entrypoint at this
tag never runs it, so the browser form is the only door.

STOP: tell the user to open https://<DOMAIN>/install/admin now, fill in a username and a password
twice, and press Create admin account, and wait. Do not continue until they confirm. That page
carries the browser title `Lychee Installer` and the words `Set up admin account.` over three
fields, and ends on `Admin account has been created.` Tell them it is the only credential this
gallery has, that nothing here relays mail so there is no password reset, and that it belongs in
their password manager before they press the button.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/install/admin
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert both, printing what you received. The first prints `403`: the setup route carries a guard
throwing `Admin User has already been set` once an administrator exists, and that is the closure
assert for this install. The second prints `200`, the gallery rather than a redirect. If the
first is anything but `403`, stop and say so rather than reporting success: no account was made
and the form is still open. Self-registration is already shut, at `user_registration_enabled` 0.

## 8. First backup and restore

Two artifacts: a dump holding albums, tags, users, access rights and photo metadata, and an
archive holding the photos plus the configuration that rebuilds the service around them.

```bash
cd /srv/lychee
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/lychee/backups/lychee-db-$(date +%F).sql.gz
sudo tar -czf /srv/lychee/backups/lychee-files-$(date +%F).tar.gz -C /srv/lychee compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/lychee/backups/
```

Assert: both exist, both non-empty, both sizes printed. Nothing goes offline, because
`--single-transaction` snapshots a running InnoDB database. `uploads` goes in whole: Lychee does
not rebuild its variants on demand, so originals alone restore broken thumbnails.

A backup on the same disk is not a backup. Run this from the user's machine:

```bash
mkdir -p ~/backups/lychee
scp vps:/srv/lychee/backups/* ~/backups/lychee/
```

To restore: `docker compose down`, `sudo rm -rf /srv/lychee/mariadb /srv/lychee/uploads`,
recreate both as step 2 does, untar the archive into /srv/lychee so `.env` and the photos are
back before anything starts, `docker compose up -d db`, wait 30 seconds for healthy, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`. Order matters: MariaDB reads its password from `.env` when it
initialises an empty directory, and a dump without the photos leaves albums listing nothing.

## 9. Updating later

Versions are listed at https://github.com/LycheeOrg/Lychee/releases and the tags carrying them at
https://hub.docker.com/r/lycheeorg/lychee/tags. Ignore any tag ending in `-legacy`: the older
nginx and php-fpm build, deprecated upstream, mounting different paths. Back up first, then edit
the lychee image line to the new tag and digest:

```bash
cd /srv/lychee
docker compose pull
docker compose up -d
docker compose logs --tail 40 lychee
```

The entrypoint runs `php artisan migrate --force` on every start, so watch until the migration
lines stop, then re-run step 7's checks. Sessions live inside the container, so this signs
everyone out once.

## 10. What will probably go wrong

The first upload will look like it has hung, and I nearly killed the container over it. There is
no worker in this stack, so `QUEUE_CONNECTION` is `sync`: the same request that carried the photo
up also decodes it, reads its EXIF and writes every resized variant before answering. A large
file off a modern camera can sit there most of a minute, and Octane's own default would have cut
it off at thirty seconds, which is why compose.yml raises `LYCHEE_MAX_EXECUTION_TIME` to 180.
Upload one photo and watch `docker compose logs -f lychee` rather than the progress bar.

## 11. Out of scope

- Do not add the worker container or switch `QUEUE_CONNECTION` to `database`. That is a third
  service, and this prompt backs up and checks two.
- Do not configure SMTP. Mail buys password reset here, and that is a second install.
- Do not configure OAuth, LDAP or WebAuthn. Each needs a client registered with somebody else,
  and step 7's account is the credential this install is built around.
- Do not add the facial-recognition or NSFW-classification containers from upstream's template.
  Both pull separate images, and neither is in this prompt's backup.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.

You are installing Lychee 7.7.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the box.

Read this before step 1. `<DOMAIN>` becomes `APP_URL`, and Lychee builds every album link and
every image URL from that value, so the hostname you pick is the one on every link you hand out.
Read step 7 to the end before you run it: there is a window in the middle where the setup form is
open to anyone who reaches the address, and it closes only when you have filled it in.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
id -u
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, a number
of 1000 or thereabouts, and your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a hostname nobody
resolves and failed attempts count against a rate limit you cannot see. The 2048 MB is real: the
app image is PHP 8.5 under FrankenPHP with ImageMagick and ffmpeg inside it, and MariaDB wants
its own on top. If `id -u` printed `0` you are root, and the container's start-up script rejects
any `PUID` outside 33 to 65534; log in as your normal user and start again.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/lychee /srv/lychee/backups /srv/lychee/uploads /srv/lychee/logs /srv/lychee/tmp
sudo install -d -m 700 /srv/lychee/mariadb
ls -la /srv/lychee
```

You should see: `backups`, `uploads`, `logs` and `tmp` owned by you, and `mariadb` at mode
`drwx------` owned by root.

If you do not: leave `mariadb` owned by root on purpose. The MariaDB image chowns its own data
directory the first time it starts and refuses one somebody else has claimed. `uploads` is the
half of this install a database dump cannot rebuild: your originals and every resized variant
Lychee makes from them. `tmp` holds upload chunks while a photo is being processed and `logs`
holds the application log, so neither is worth archiving.

## 3. Secrets

Three secrets, all generated here on the server and written straight into a file only you can
read. `APP_KEY` is Laravel's application key, and the container refuses to start without one that
decodes to exactly 32 bytes. The other two are the password for the `lychee` database user and
the MariaDB root password. Lychee ships no account and no admin token, so the administrator is
the one you create in the browser in step 7.

Replace `<DOMAIN>` in the first line with your real hostname before you paste this.

```bash
umask 077
cat > /srv/lychee/.env <<EOF
APP_URL=https://<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" >> /srv/lychee/.env
chmod 600 /srv/lychee/.env
umask 022
ls -l /srv/lychee/.env
```

You should see: mode `-rw-------`, your own username as owner, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines into different shells. Run `chmod 600 /srv/lychee/.env` and carry on. If the file
already existed from an earlier attempt, this block has now replaced all three values, which is
fine before the database exists and a problem afterwards: MariaDB keeps the password it was
created with, so a changed `DB_PASSWORD` against an existing directory produces an access-denied
error that never mentions passwords.

Do not paste that file, any of the three values, or any command output containing them into this
chat window. Nothing in this install ever asks you to read them out: Compose reads the file itself
for the `${...}` substitutions in step 4, and no browser form here wants a database password.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/lychee/compose.yml <<'EOF'
# Lychee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://lycheeorg.dev/docs/getting-started/docker/
#   compose template . https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   entrypoint ....... https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#
# Lychee plus the MariaDB holding albums, users, tags and photo metadata.
# MariaDB because upstream's README compose and the DB_CONNECTION default both
# say mysql; the image is the FrankenPHP build on the plain version tag, not a
# -legacy one. Every ${...} comes from /srv/lychee/.env, mode 600, which
# Compose reads and never mounts. Digests read from the registries on
# 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: lychee-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: lychee
      MARIADB_USER: lychee
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/lychee/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  lychee:
    image: lycheeorg/lychee:v7.7.2@sha256:daacbba4876b3b73e4d46be1f4858f43cb2256c9c506c0ab7f333a8d9c993c00
    container_name: lychee
    restart: unless-stopped
    environment:
      # No APP_KEY, no boot: the entrypoint checks it decodes to 32 bytes.
      APP_KEY: ${APP_KEY}
      # Every album link and image URL is built from APP_URL. Caddy terminates
      # TLS and speaks plain http here, so the scheme is forced, not guessed.
      APP_URL: ${APP_URL}
      APP_FORCE_HTTPS: "true"
      APP_ENV: production
      APP_DEBUG: "false"
      TIMEZONE: UTC
      DB_CONNECTION: mysql
      DB_HOST: db
      # The entrypoint waits on this port with nc, so it is never left unset.
      DB_PORT: "3306"
      DB_DATABASE: lychee
      DB_USERNAME: lychee
      DB_PASSWORD: ${DB_PASSWORD}
      # sync: the request that uploads a photo also builds its thumbnails.
      # database would queue that for a worker this file does not run, and
      # Octane cuts a request at 30s by default, that upload's real ceiling.
      QUEUE_CONNECTION: sync
      LYCHEE_MAX_EXECUTION_TIME: "180"
      # The entrypoint moves its www-data to these before dropping privileges,
      # so the login user owns the photo files.
      PUID: ${PUID}
      PGID: ${PGID}
    volumes:
      # uploads is the half of the backup a database dump cannot rebuild.
      - /srv/lychee/uploads:/app/public/uploads
      - /srv/lychee/logs:/app/storage/logs
      - /srv/lychee/tmp:/app/storage/tmp
    healthcheck:
      # /up answers 200 before any account exists, which the app root does not.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8000/up || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy on this host alone reaches 8195.
      - "127.0.0.1:8195:8000"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/lychee && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `variable is not set` names a line missing from /srv/lychee/.env, so go back to
step 3. A YAML error with a line number usually means the paste was truncated; `wc -l
/srv/lychee/compose.yml` should print a number in the eighties. No database port is published
here, and no credential is written here either: every value arrives from .env.

## 5. Caddy and TLS

Replace `<DOMAIN>` with your hostname in the block below before pasting it. The first line copies
the existing Caddyfile, because a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-lychee
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Lychee · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://lycheeorg.dev/docs/getting-started/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and every album link Lychee prints is built from it.

<DOMAIN> {
	encode zstd gzip

	# Lychee sends X-Content-Type-Options and Referrer-Policy itself. This
	# block adds only what belongs to whatever terminates TLS: HSTS, which
	# Lychee leaves off because it cannot know it is behind a certificate.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		-Server
	}

	# 8195 is the loopback port compose publishes here, not a container port
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8195
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from the reload.

If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-lychee /etc/caddy/Caddyfile`, reload, and read what validate
objected to. The usual cause is a `<DOMAIN>` left literal in the site line. Caddy asks for the
certificate on the first request to the hostname and renews it on its own; there is nothing to
schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active` and rules for 80/tcp, 443/tcp and 443/udp, and nothing about
8195 or 3306.

If you do not: on a box Prompt Zero configured these three change nothing, and that is the
expected result. 8195 is bound to 127.0.0.1 in compose.yml and 3306 is never published at all, so
neither has a host port a firewall rule could apply to. If either appears in the output, remove
it: `sudo ufw delete allow 8195`.

## 7. Start and verify

The first start migrates the database and caches the config, routes and views, so give it a
minute before concluding anything.

```bash
cd /srv/lychee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/up | grep -c 'Lychee is up'
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/install/admin | grep -c 'Set up admin account'
```

You should see, in order: the loop reaching `200`, then a number above `0`, then
`307 https://<DOMAIN>/install/admin`, then `1`.

If you do not: the `307` is the one worth understanding. Lychee redirects every page to its admin
setup form until an administrator exists, so a redirect at the root is correct rather than broken.
If the loop never reaches `200`, run `docker compose logs --tail 20 db` first, because a database
that never reports healthy is step 2 done wrong, and `docker compose logs --tail 40 lychee`
second; `APP_KEY is not set` there sends you back to step 3. A lasting `502` is step 5. A
container that says `Up` proves nothing on its own.

That `307` is also the security problem in this install, and it has a clock on it. The setup form
has to be unauthenticated, because there is no account yet to authenticate against, and it stays
open to anyone who reaches your hostname until somebody submits it. Whoever submits it first is
the administrator of your gallery. There is no way around it from the shell: the image carries a
create-admin helper that reads `ADMIN_USER` and `ADMIN_PASSWORD`, and the entrypoint in this
version never runs it. Do the next paragraph now, not tomorrow.

Open https://<DOMAIN>/install/admin in your browser. The page has the browser title
`Lychee Installer` and the words `Set up admin account.` above three fields: Username, Password
and Confirm password. Fill them in and press `Create admin account`. That account is the only
credential this gallery has and no mail is relayed from this install, so there is no password
reset and it goes in your password manager before you press the button.

You should see: `Admin account has been created.` and an `Open Lychee` link.

If you do not: a complaint under the Password field is the password rule, so try a longer one. A
page that reloads with `Admin User has already been set` means somebody got there first, and on a
brand new install that somebody is almost certainly your own earlier browser tab; log in with the
credentials you used there. If it was not you, take the stack down with `docker compose down`,
delete /srv/lychee/mariadb, and start step 7 again on a hostname nobody has been given yet.

Now prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/install/admin
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see, in order: `403`, then `200`.

If you do not: the `403` is the one that matters. Lychee's setup route carries a guard that throws
`Admin User has already been set` once an administrator exists, and that 403 is your proof the
form is no longer a way in. A `307` there means no account was created and the form is still open
to the internet, so go back and create it. The `200` on the root is the gallery itself, now that
the redirect to the setup form is gone. Self-registration is a separate door and it is already
shut: Lychee ships `user_registration_enabled` set to `0` and nothing here changes it.

## 8. First backup and restore

Two artifacts. The dump holds the albums, tags, users, access rights and every photo's metadata.
The file archive holds the photos and the configuration that rebuilds the service around them.

```bash
cd /srv/lychee
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/lychee/backups/lychee-db-$(date +%F).sql.gz
sudo tar -czf /srv/lychee/backups/lychee-files-$(date +%F).tar.gz -C /srv/lychee compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh /srv/lychee/backups/
```

You should see: two files, the dump a few tens of kilobytes and the archive small on a fresh
install, because the only photos in it are the ones you have not uploaded yet. Nothing goes
offline: `--single-transaction` snapshots a running InnoDB database.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed
and the shell created the file anyway. Run that line again without `| gzip` to read the error.
`logs` and `tmp` are left out of the archive on purpose. `uploads` goes in whole rather than
originals only, because Lychee does not rebuild its resized variants on demand and an archive of
originals alone would restore a gallery of broken thumbnails.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/lychee
scp vps:/srv/lychee/backups/* ~/backups/lychee/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/lychee/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty gallery:

```bash
cd /srv/lychee
docker compose down
sudo rm -rf /srv/lychee/mariadb /srv/lychee/uploads
sudo install -d -m 700 /srv/lychee/mariadb
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/lychee/uploads
sudo tar -xzf /srv/lychee/backups/lychee-files-$(date +%F).tar.gz -C /srv/lychee compose.yml .env uploads
docker compose up -d db
sleep 30
gunzip -c /srv/lychee/backups/lychee-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/install/admin
```

You should see: no output from the restore itself, then `403` from the last line, which means your
administrator account came back from a database and a directory that were both deleted.

If you do not: `Access denied for user 'lychee'` means the archive did not restore `.env` before
MariaDB initialised its empty directory, so MariaDB invented a different password. Repeat the
block and check the tar step runs before `docker compose up -d db`. A `307` from the last line
means the dump did not load, so your account is gone and the setup form is open again: rerun the
`gunzip -c` line and read its error. That ordering is the whole lesson, and the photos are the
other half of it: restore the dump without `uploads` and every album lists files that are not
there.

## 9. Updating later

New versions are listed at https://github.com/LycheeOrg/Lychee/releases and the tags carrying them
at https://hub.docker.com/r/lycheeorg/lychee/tags. Ignore any tag ending in `-legacy`: that is the
older nginx and php-fpm build, which upstream's Docker guide marks deprecated and which mounts
different paths inside the container. Take both backup artifacts first, then edit the lychee image
line in /srv/lychee/compose.yml to the new tag and its digest.

```bash
cd /srv/lychee
docker compose pull
docker compose up -d
docker compose logs --tail 40 lychee
```

You should see: migration lines going past, then the server reporting itself ready.

If you do not: the entrypoint runs the database migration on every start, so an error there is the
one to read rather than anything on the web page. Re-run step 7's `/up` and root checks before
calling the update done. Two things will surprise you afterwards: sessions live inside the
container rather than a mounted directory, so recreating it signs you out and you log in again,
and the start-up permission sweep walks every file under `uploads`, which on a large library adds
minutes to a restart.

## 10. What will probably go wrong

Your first upload will look like it has hung, and I nearly killed the container over it. There is
no worker in this stack, so `QUEUE_CONNECTION` is `sync`: the same request that carried the photo
up also decodes it, reads its EXIF and writes every resized variant before it answers. A large
file off a modern camera can sit there for most of a minute with nothing moving in the browser,
and the server's own default would have cut it off at thirty seconds, which is why compose.yml
raises `LYCHEE_MAX_EXECUTION_TIME` to 180. Upload one photo, wait, and watch
`docker compose logs -f lychee` rather than the progress bar. If a batch of large files genuinely
times out, that is the point where the worker container becomes worth adding.

## 11. Out of scope

- Do not add the worker container or switch `QUEUE_CONNECTION` to `database`. That is a third
  service with its own restart loop, and this page backs up and checks two.
- Do not configure SMTP. Lychee works without mail, and what mail buys here is password reset,
  which is a second install to do properly.
- Do not configure OAuth, LDAP or WebAuthn. Each needs a client registered with somebody else,
  and the account you made in step 7 is the credential this install is built around.
- Do not add the facial-recognition or NSFW-classification containers from upstream's template.
  Both pull separate images, and neither is in the backup this page takes.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Lychee 7.7.2, with the MariaDB it keeps albums and photo metadata in, under
~/selfhost/lychee, answering at http://localhost:8195.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install. Lychee is a
gallery you hand people a link to, and this one answers at http://localhost:8195, so a link sent
to a client opens nothing. What they get is their own catalogue, with no cap but the disk.

Detect the OS and measure:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the ID
and codename print next, for step 2. Lychee plus MariaDB needs 2048 MB of RAM available and 10 GB
free on the home disk, and both images publish amd64 and arm64. Under either floor, stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/lychee/uploads ~/selfhost/lychee/logs ~/selfhost/lychee/tmp ~/selfhost/lychee/backups
ls -la ~/selfhost/lychee
```

Assert: four directories, owned by the user. `uploads` is the half a database dump cannot
rebuild; `tmp` and `logs` are working space. No ownership fix runs here, and step 5 says why.

## 4. Secrets

Three, all generated here. `APP_KEY` is the Laravel application key and the container refuses to
boot without one decoding to exactly 32 bytes; the others are the `lychee` database user's and
MariaDB root passwords. Lychee ships no account. Print none of the three, in chat, summary or log.

```bash
umask 077
cat > ~/selfhost/lychee/.env <<EOF
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/lychee/.env
umask 022
ls -l ~/selfhost/lychee/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so this runs the same on all three, and
Compose reads the file for the `${...}` substitutions without mounting it. On Windows mode bits
are advisory; the real boundary is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/lychee/compose.yml <<'EOF'
# Lychee · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker guide ..... https://lycheeorg.dev/docs/getting-started/docker/
#   compose template . https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   entrypoint ....... https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#
# Lychee plus the MariaDB holding albums, users, tags and photo metadata.
# MariaDB because upstream's README compose and the DB_CONNECTION default both
# say mysql. Paths are relative to ~/selfhost/lychee/, so one file works on
# macOS, Linux and Windows, and uploads stays a bind mount so the photos show
# up in Finder or Explorer. The database is a named volume: MariaDB chowns its
# data dir to a uid Docker Desktop cannot grant on a home bind mount. Digests
# read from the registries on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: lychee-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: lychee
      MARIADB_USER: lychee
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - lychee-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  lychee:
    image: lycheeorg/lychee:v7.7.2@sha256:daacbba4876b3b73e4d46be1f4858f43cb2256c9c506c0ab7f333a8d9c993c00
    container_name: lychee
    restart: unless-stopped
    environment:
      # No APP_KEY, no boot: the entrypoint checks it decodes to 32 bytes.
      APP_KEY: ${APP_KEY}
      # Every album link and image URL is built from APP_URL, and on this path
      # the loopback address is the only address this gallery has.
      APP_URL: http://localhost:8195
      APP_FORCE_HTTPS: "false"
      APP_ENV: production
      APP_DEBUG: "false"
      TIMEZONE: UTC
      DB_CONNECTION: mysql
      DB_HOST: db
      # The entrypoint waits on this port with nc, so it is never left unset.
      DB_PORT: "3306"
      DB_DATABASE: lychee
      DB_USERNAME: lychee
      DB_PASSWORD: ${DB_PASSWORD}
      # sync: the request that uploads a photo also builds its thumbnails.
      # database would queue that for a worker this file does not run, and
      # Octane cuts a request at 30s by default, that upload's real ceiling.
      QUEUE_CONNECTION: sync
      LYCHEE_MAX_EXECUTION_TIME: "180"
      # No PUID here, unlike the VPS file: the start-up script takes only
      # 33 to 65534, and `id -u` on macOS or Git Bash sits outside that.
    volumes:
      # uploads is the half of the backup a database dump cannot rebuild.
      - ./uploads:/app/public/uploads
      - ./logs:/app/storage/logs
      - ./tmp:/app/storage/tmp
    healthcheck:
      # /up answers 200 before any account exists, which the app root does not.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8000/up || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8195.
      - "127.0.0.1:8195:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  lychee-dbdata:
EOF
cd ~/selfhost/lychee && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Two services, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: no hostname to resolve, no public name to
attest, nothing beyond loopback to close. Browsers treat http://localhost as a secure context.

8195 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet. That is the trade this path makes, and its point. Confirm:

```bash
grep -c '"127.0.0.1:' ~/selfhost/lychee/compose.yml
```

Assert: `1`, the published-port line. MariaDB publishes no host port, so 3306 never appears.

## 7. Start and verify

The first start migrates the database and caches config, routes and views.

```bash
cd ~/selfhost/lychee
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8195/up); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8195/up | grep -c 'Lychee is up'
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:8195/
curl -sS http://localhost:8195/install/admin | grep -c 'Set up admin account'
```

Assert all four, printing what you received. The loop ends on `200`. The grep prints above `0`,
`Lychee is up` being the heading the health page renders. The third prints
`307 http://localhost:8195/install/admin`, Lychee sending every page to the setup form until an
administrator exists. The last prints `1`, the form itself. A running container is not success:
on any miss, stop and run `docker compose logs --tail 40 lychee` and `--tail 20 db`, where a
database never reporting healthy or `APP_KEY is not set` is step 4 and `port is already
allocated` is step 10. That form is the only door: the create-admin helper is never run here.

STOP: tell the user to open http://localhost:8195/install/admin, fill in a username and a password
twice, and press Create admin account, and wait. Do not continue until they confirm. That page
carries the browser title `Lychee Installer` and the words `Set up admin account.` and ends on
`Admin account has been created.` It is this gallery's only credential, no mail is relayed so
there is no reset, and it belongs in a password manager first.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8195/install/admin
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8195/
```

Assert both, printing what you received. The first prints `403`: the setup route carries a guard
throwing `Admin User has already been set` once an administrator exists. The second prints `200`,
the gallery rather than a redirect. Anything but `403`, stop and say so rather than report
success. Self-registration is already shut, at `user_registration_enabled` 0.

## 8. First backup and restore

Two artifacts: a dump holding albums, tags, users and photo metadata, and an archive of photos.

```bash
cd ~/selfhost/lychee
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/lychee/backups/lychee-db-$(date +%F).sql.gz
tar -C ~/selfhost/lychee -czf ~/selfhost/lychee/backups/lychee-files-$(date +%F).tar.gz compose.yml .env uploads
ls -lh ~/selfhost/lychee/backups/
```

Assert: both exist, both non-empty, both sizes printed. Nothing goes offline, because
`--single-transaction` snapshots a running InnoDB database, and on Linux the photo files are
world-readable, so `tar` needs no privilege.

Both sit on the same disk as the photos, which is not a backup, and on a laptop the disk and the
machine fail together. Ask for a destination off this computer and copy both there with `cp`; in
Git Bash that is `/d/Backups`, not `D:\Backups`. Assert: both filenames are listed there, or say
plainly that this install has no backup.

To restore: `cd ~/selfhost/lychee`, `docker compose down -v`, `rm -rf uploads`, untar the archive
there so `.env` and the photos are back before anything starts, `docker compose up -d db`, wait
30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
then `docker compose up -d`. MariaDB reads `.env` when it initialises an empty volume, and a dump
without the photos leaves albums listing nothing.

## 9. Updating later

Versions are listed at https://github.com/LycheeOrg/Lychee/releases and the tags carrying them at
https://hub.docker.com/r/lycheeorg/lychee/tags. Ignore any `-legacy` tag: the older nginx build,
deprecated upstream. Back up first, then edit the lychee image line to the new tag and digest:

```bash
cd ~/selfhost/lychee
docker compose pull
docker compose up -d
docker compose logs --tail 40 lychee
```

The entrypoint migrates the database on every start, so watch until those lines stop, then re-run
step 7's checks. Sessions live in the container, so this signs the user out.

## 10. What will probably go wrong

I rebooted, opened http://localhost:8195 and got a connection refused that reads like a lost
library. Nothing was lost: Docker Desktop had not started with the session, so nothing listened
on 8195, and `restart: unless-stopped` only acts once the daemon is up. Turn on its
start-at-login setting, and after a reboot run `cd ~/selfhost/lychee && docker compose up -d`
before concluding anything broke. `lsof -nP -iTCP:8195 -sTCP:LISTEN` finds a port already taken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8195 to 0.0.0.0 for a phone. That publishes the gallery to every network joined.
- Do not add the worker container or switch `QUEUE_CONNECTION` to `database`. This prompt backs
  up and checks two services, and that would be a third.
- Do not configure SMTP, OAuth or LDAP. Mail from a sleeping machine is a queue, not a delivery.
````

## docker-compose.yml

```yaml
# Lychee · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker guide ..... https://lycheeorg.dev/docs/getting-started/docker/
#   compose template . https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   entrypoint ....... https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#
# Lychee plus the MariaDB holding albums, users, tags and photo metadata.
# MariaDB because upstream's README compose and the DB_CONNECTION default both
# say mysql; the image is the FrankenPHP build on the plain version tag, not a
# -legacy one. Every ${...} comes from /srv/lychee/.env, mode 600, which
# Compose reads and never mounts. Digests read from the registries on
# 2026-08-14; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: lychee-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: lychee
      MARIADB_USER: lychee
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/lychee/mariadb:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  lychee:
    image: lycheeorg/lychee:v7.7.2@sha256:daacbba4876b3b73e4d46be1f4858f43cb2256c9c506c0ab7f333a8d9c993c00
    container_name: lychee
    restart: unless-stopped
    environment:
      # No APP_KEY, no boot: the entrypoint checks it decodes to 32 bytes.
      APP_KEY: ${APP_KEY}
      # Every album link and image URL is built from APP_URL. Caddy terminates
      # TLS and speaks plain http here, so the scheme is forced, not guessed.
      APP_URL: ${APP_URL}
      APP_FORCE_HTTPS: "true"
      APP_ENV: production
      APP_DEBUG: "false"
      TIMEZONE: UTC
      DB_CONNECTION: mysql
      DB_HOST: db
      # The entrypoint waits on this port with nc, so it is never left unset.
      DB_PORT: "3306"
      DB_DATABASE: lychee
      DB_USERNAME: lychee
      DB_PASSWORD: ${DB_PASSWORD}
      # sync: the request that uploads a photo also builds its thumbnails.
      # database would queue that for a worker this file does not run, and
      # Octane cuts a request at 30s by default, that upload's real ceiling.
      QUEUE_CONNECTION: sync
      LYCHEE_MAX_EXECUTION_TIME: "180"
      # The entrypoint moves its www-data to these before dropping privileges,
      # so the login user owns the photo files.
      PUID: ${PUID}
      PGID: ${PGID}
    volumes:
      # uploads is the half of the backup a database dump cannot rebuild.
      - /srv/lychee/uploads:/app/public/uploads
      - /srv/lychee/logs:/app/storage/logs
      - /srv/lychee/tmp:/app/storage/tmp
    healthcheck:
      # /up answers 200 before any account exists, which the app root does not.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8000/up || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: Caddy on this host alone reaches 8195.
      - "127.0.0.1:8195:8000"
    depends_on:
      db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Lychee · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker guide ..... https://lycheeorg.dev/docs/getting-started/docker/
#   compose template . https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   entrypoint ....... https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#
# Lychee plus the MariaDB holding albums, users, tags and photo metadata.
# MariaDB because upstream's README compose and the DB_CONNECTION default both
# say mysql. Paths are relative to ~/selfhost/lychee/, so one file works on
# macOS, Linux and Windows, and uploads stays a bind mount so the photos show
# up in Finder or Explorer. The database is a named volume: MariaDB chowns its
# data dir to a uid Docker Desktop cannot grant on a home bind mount. Digests
# read from the registries on 2026-08-14.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: lychee-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: lychee
      MARIADB_USER: lychee
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - lychee-dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      retries: 20
    # No `ports:` at all: 3306 is reachable only from the other container.

  lychee:
    image: lycheeorg/lychee:v7.7.2@sha256:daacbba4876b3b73e4d46be1f4858f43cb2256c9c506c0ab7f333a8d9c993c00
    container_name: lychee
    restart: unless-stopped
    environment:
      # No APP_KEY, no boot: the entrypoint checks it decodes to 32 bytes.
      APP_KEY: ${APP_KEY}
      # Every album link and image URL is built from APP_URL, and on this path
      # the loopback address is the only address this gallery has.
      APP_URL: http://localhost:8195
      APP_FORCE_HTTPS: "false"
      APP_ENV: production
      APP_DEBUG: "false"
      TIMEZONE: UTC
      DB_CONNECTION: mysql
      DB_HOST: db
      # The entrypoint waits on this port with nc, so it is never left unset.
      DB_PORT: "3306"
      DB_DATABASE: lychee
      DB_USERNAME: lychee
      DB_PASSWORD: ${DB_PASSWORD}
      # sync: the request that uploads a photo also builds its thumbnails.
      # database would queue that for a worker this file does not run, and
      # Octane cuts a request at 30s by default, that upload's real ceiling.
      QUEUE_CONNECTION: sync
      LYCHEE_MAX_EXECUTION_TIME: "180"
      # No PUID here, unlike the VPS file: the start-up script takes only
      # 33 to 65534, and `id -u` on macOS or Git Bash sits outside that.
    volumes:
      # uploads is the half of the backup a database dump cannot rebuild.
      - ./uploads:/app/public/uploads
      - ./logs:/app/storage/logs
      - ./tmp:/app/storage/tmp
    healthcheck:
      # /up answers 200 before any account exists, which the app root does not.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8000/up || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8195.
      - "127.0.0.1:8195:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  lychee-dbdata:
```

## Caddyfile

```text
# Lychee · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://lycheeorg.dev/docs/getting-started/docker/ and
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also APP_URL in .env, and every album link Lychee prints is built from it.

<DOMAIN> {
	encode zstd gzip

	# Lychee sends X-Content-Type-Options and Referrer-Policy itself. This
	# block adds only what belongs to whatever terminates TLS: HSTS, which
	# Lychee leaves off because it cannot know it is behind a certificate.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		-Server
	}

	# 8195 is the loopback port compose publishes here, not a container port
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8195
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Lychee · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=photos.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://lycheeorg.dev/docs/getting-started/docker/
#   https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker-compose.yaml
#   https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/entrypoint.sh
#   https://github.com/LycheeOrg/Lychee/blob/v7.7.2/docker/scripts/01-validate-env.sh
#
# The image is the LycheeOrg project's own FrankenPHP build, pinned by tag and
# digest. Three secrets are generated here, on this machine: the Laravel
# APP_KEY, the lychee database user's password and the MariaDB root password.
# All three go into /srv/lychee/.env with mode 600 and none is ever printed.
#
# Lychee ships no account. Whoever first submits the form this script leaves
# waiting at https://<DOMAIN_HOST>/install/admin becomes the administrator of
# the gallery, so do that immediately: the closing notes carry the step and the
# check that proves the form is shut afterwards.
#
# DOMAIN_HOST is the address every album link and image URL will carry.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/lychee}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. photos.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; FrankenPHP plus ImageMagick plus MariaDB wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB, and a photo library wants more"

# The container's start-up script exits if PUID falls outside this range, and
# these are the values written into .env below.
run_uid="$(id -u)"
run_gid="$(id -g)"
[ "$run_uid" -ge 33 ] && [ "$run_uid" -le 65534 ] || die "your uid is ${run_uid}; the Lychee image accepts PUID 33 to 65534 only. Run this as a normal login user."

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || echo "")"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# mariadb stays root-owned at 700: the MariaDB image chowns its own data
# directory and refuses one somebody claimed first. uploads is the half of this
# install a database dump cannot rebuild. logs and tmp are working space.

sudo install -d -m 750 -o "$run_uid" -g "$run_gid" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/uploads" "$APP_DIR/logs" "$APP_DIR/tmp"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# APP_KEY has to decode to exactly 32 bytes or the container refuses to boot.
# Hex for the two database passwords, because upstream warns that a DB_PASSWORD
# carrying punctuation has to be quoted. Read them back later, if you ever need
# to, with
#   sudo grep DB_PASSWORD /srv/lychee/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		APP_URL=https://${DOMAIN_HOST}
		APP_KEY=base64:$(openssl rand -base64 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	printf 'PUID=%s\nPGID=%s\n' "$run_uid" "$run_gid" >> "$APP_DIR/.env"
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-lychee"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8195 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8195 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first start runs the database migrations and caches config, routes and
# views, so the wait loop below is generous on purpose.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/up"
code=""
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/up" || echo 000)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "$code" = "200" ] || die "/up answered ${code}. Check: docker compose logs --tail 40 lychee"

curl -sS "https://${DOMAIN_HOST}/up" | grep -q 'Lychee is up' \
	|| die "/up answered 200 without the 'Lychee is up' heading. Check: docker compose logs --tail 40 lychee"

# Before an administrator exists, Lychee redirects every page to the setup form.
root_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || echo 000)"
[ "$root_code" = "307" ] || die "the site root answered ${root_code}, not the 307 an un-claimed Lychee sends. Stop and investigate."

curl -sS "https://${DOMAIN_HOST}/install/admin" | grep -q 'Set up admin account' \
	|| die "the setup form is not being served. Check: docker compose logs --tail 40 lychee"

# --- 7. The first backup, before day one ends --------------------------------
#
# Taken now, with an empty gallery, so the restore path is proved before there
# is anything to lose. The archive carries .env, compose.yml, the uploads tree
# and the live Caddy site block rather than the <DOMAIN> template.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > "$APP_DIR/backups/lychee-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/lychee-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env uploads -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/lychee-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/lychee-files-${STAMP}.tar.gz" ] || die "the file archive is empty"

cat <<-DONE

	Lychee is serving its admin setup form at https://${DOMAIN_HOST}/install/admin

	  1. Open that page NOW and claim the account. The form is unauthenticated,
	     because there is no account yet to authenticate against, and whoever
	     submits it first is the administrator of this gallery. Fill in a
	     username and a password twice and press Create admin account. You
	     should land on "Admin account has been created." That account is this
	     gallery's only credential and no mail is relayed from this install, so
	     there is no password reset: put it in your password manager first.
	  2. Then prove the door is shut. This is the security check the script
	     cannot run for you, because the account does not exist until you make
	     it:
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/install/admin
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/
	     The first must print 403, the guard that throws "Admin User has
	     already been set". The second must print 200, the gallery itself. A
	     307 on the first means no account was created and the form is still
	     open to the internet.
	  3. Self-registration is already off. Lychee ships user_registration_enabled
	     set to 0 and nothing here changes it, so the form above was the only
	     way in.
	  4. Your first upload will look like it has hung. There is no worker in
	     this stack, so the request that carries a photo up also builds every
	     resized variant before it answers. Watch
	       cd $APP_DIR && docker compose logs -f lychee
	     rather than the progress bar.
	  5. First backup written to $APP_DIR/backups: a database dump and a file
	     archive holding .env, compose.yml, the uploads tree and the live Caddy
	     site block. Take them again once you have an account, because this
	     pair predates it. They sit on the same disk as the data, which is not
	     a backup. Copy them off the box tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/lychee/

DONE
```

## Also evaluated

Ranked below Lychee for this swap. The prompts above install Lychee only.

- **Piwigo** — A photo gallery on your own domain, with albums, tags and per-album permissions, and no cap on what you upload. The other serious answer, and the one to pick if a decade of archive matters more than the first impression. Piwigo has twenty years behind it, per-album permissions for named users and groups, an extension gallery and a documented upgrade path across major versions, and it keeps the top spot on its own Flickr Pro page for exactly those reasons. Here Lychee goes first because the thing a Pixieset customer is replacing is a gallery a client opens, and Lychee's is the closer one: quicker to stand up, and built around handing somebody a link rather than around cataloguing. Neither of them sells anything or proofs anything.

The page this mirrors: https://caniselfhostit.com/self-host/pixieset/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
