Can I self-host Pixieset?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Lychee. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $16 a month you stop paying Pixieset — $192 a year on the Plus plan.
Why people pay for Pixieset
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Pixieset is not photo hosting, it is the back half of a photographer's job sold as one subscription: a client gallery that proofs and delivers, a storefront that takes the money for prints and downloads, a branded mobile album the client keeps on their phone, and email campaigns that chase the sale. The line on the invoice is storage, from 3 GB free to unlimited, and the reason people stay is that the proofing, the checkout and the fulfilment are already wired to each other.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | 3 GB of photo storage, unlimited galleries, up to 3 mobile apps, and store sales carry a 15% commission fee. |
| Basic | $8/mo | $8/mo billed annually, or $10/month. 10 GB of photo storage, 30 minutes of HD video, custom domain and branding, full-resolution download, 100 email campaigns a month, and commission-free sales. |
| Plusthe plan this page prices against | $16/mo | $16/mo billed annually, or $20/month. Everything in Basic plus 100 GB of photo storage and 500 email campaigns a month. |
| Pro | $24/mo | $24/mo billed annually, or $30/month. Labelled RECOMMENDED on the pricing page. 1000 GB of photo storage, bulk share collections and RAW upload. |
| Ultimate | $40/mo | $40/mo billed annually, or $50/month. Unlimited photo storage, with RAW capped at 1000 GB, and 10,000 email campaigns a month. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: high
Replaced by Lychee
One project, named before the prompt, so you know what you are about to install.
A photo gallery you host and hand people a link to, with albums, tags, per-album passwords and no upload cap but your own disk.
The honest half of the swap, and it is worth naming which half. Lychee gives you the gallery: albums you upload to, a link you send, a password on the album if you want one, EXIF and tags, zip download for the client, and a library that stops growing when your disk does rather than when your plan does. It gives you none of the workflow Pixieset wraps around that. There is no proofing, no selection to approve and hand back, no storefront a client can check out of, no card payment, no automatic print fulfilment, no email campaign, no branded mobile app with your name on it. Lychee does ship webshop code, and every route of it is gated behind the paid Pro edition, where prints still have no integration with a printing service. Take this if what you actually pay Pixieset for is a good-looking gallery on your own domain that nobody can meter.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Lycheeour pickONE EVENING— setup effort 2 of 4
A photo gallery you host and hand people a link to, with albums, tags, per-album passwords and no upload cap but your own disk.
The honest half of the swap, and it is worth naming which half. Lychee gives you the gallery: albums you upload to, a link you send, a password on the album if you want one, EXIF and tags, zip download for the client, and a library that stops growing when your disk does rather than when your plan does. It gives you none of the workflow Pixieset wraps around that. There is no proofing, no selection to approve and hand back, no storefront a client can check out of, no card payment, no automatic print fulfilment, no email campaign, no branded mobile app with your name on it. Lychee does ship webshop code, and every route of it is gated behind the paid Pro edition, where prints still have no integration with a printing service. Take this if what you actually pay Pixieset for is a good-looking gallery on your own domain that nobody can meter.
- PiwigoONE EVENING— setup effort 2 of 4
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 swap
You'd run
Lychee
ONE EVENING · ~90 min to running · 2048 MB RAM
Pixieset Plus · vendor list price · checked 2026-08-14 · source
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Lychee: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
333 lines · 14,977 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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.323 lines · 14,986 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout84 lines
# 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:agent-readable mirror: /self-host/pixieset.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned82 lines
# 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_healthyCaddyfilethe hostname and TLS25 lines
# 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.shthe same install, no agent187 lines
#!/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/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Pixieset.
- Pixieset sells a workflow and this is one part of it. There is no proofing here: no client favourite list to approve, no selection handed back, nothing named proof anywhere in the source. There is no store and no client payment. Lychee does carry a webshop, and every route of it sits behind the paid Pro edition, where upstream's own comparison still says prints come with no integration with printing services. There are no email campaigns and no per-client branded mobile app. What you keep is the gallery, the link, the password on the album, the download, and a library capped by your disk instead of by a plan.
- Free covers the gallery; some of what looks standard is a paid tier. Lychee is MIT and the free edition has unlimited photos, albums and users. Watermarking, secured image links, per-user quotas, statistics and user groups are Supporter Edition, sold at five euros a month, and the webshop needs the Pro add-on above that. Nothing in this install is crippled without them, but a feature list read on the project site is not all free.
- You own the disk, and a photo library is the one thing on this site that grows without asking. Originals and every resized variant Lychee derives from them share the uploads directory, so the number on your hosting invoice becomes the number that decides how many client galleries you keep. On Pixieset the equivalent decision is a plan change; here it is a bigger volume.
- A backup is two artifacts and neither is optional. Albums, tags, users and access rights are rows in MariaDB; the photos are files. Lychee does not rebuild its resized copies on demand, so restore the database alone and you get albums full of broken thumbnails, restore the files alone and you get an index of nothing. Both go off the box together, and a pair you have never restored is not a backup.
- No mail, so no password reset, and no worker, so uploads take their time. This install deliberately configures no SMTP, which means the administrator password you choose in the browser is the only copy that exists. It also runs no queue worker, so the request that uploads a photo also builds every variant before it answers: correct, and slower than a progress bar suggests.
Where this came from
“One click and every photo and album is ready for the public. You can also protect albums with passwords if you want. It's under your control.”
- A new Lychee has no account at all, and the first visit creates one. Until an administrator exists, every page answers 307 to the admin setup form, and that form is unauthenticated because there is nothing yet to authenticate against: SetUpAdminRequest only starts refusing, with a 403 and the message Admin User has already been set, once a user with may_administrate exists. source
- Self-registration is off in a fresh install and this entry does not turn it on. The migration that introduces user_registration_enabled seeds it at 0, and RegistrationRequest refuses every sign-up while that value is false. source
- As of version 7 the default Docker image is FrankenPHP with Laravel Octane rather than nginx and php-fpm. Upstream's Docker guide lists the -legacy tags as the nginx and php-fpm build and marks them deprecated, which is why this install pins the plain version tag and says to ignore the -legacy line. source
- The container's entrypoint validates APP_KEY decodes to 32 bytes, waits on DB_HOST and DB_PORT with nc, then runs php artisan migrate --force on every start. It sources five helper scripts and never sources create-admin-user.sh, so ADMIN_USER and ADMIN_PASSWORD do nothing at this tag and the browser form is the only way to make the first account. source
- Lychee's webshop exists in the codebase and is not available to a free install: every route under /Shop, including the basket, the checkout and the Mollie and PayPal integrations, sits behind the support:pro middleware, under a comment reading Only for SE sorry. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Pixieset?
Not Pixieset itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Lychee. A photo gallery you host and hand people a link to, with albums, tags, per-album passwords and no upload cap but your own disk. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Pixieset?
Lychee. A photo gallery you host and hand people a link to, with albums, tags, per-album passwords and no upload cap but your own disk. The honest half of the swap, and it is worth naming which half. Lychee gives you the gallery: albums you upload to, a link you send, a password on the album if you want one, EXIF and tags, zip download for the client, and a library that stops growing when your disk does rather than when your plan does. It gives you none of the workflow Pixieset wraps around that. There is no proofing, no selection to approve and hand back, no storefront a client can check out of, no card payment, no automatic print fulfilment, no email campaign, no branded mobile app with your name on it. Lychee does ship webshop code, and every route of it is gated behind the paid Pro edition, where prints still have no integration with a printing service. Take this if what you actually pay Pixieset for is a good-looking gallery on your own domain that nobody can meter. Also evaluated and ranked below it: Piwigo. The prompt on this page installs Lychee only. Lychee is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Pixieset?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Lychee itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Pixieset Plus, $16/mo — $192 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Lychee install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Lychee on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Lychee on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer the gallery answers only at http://localhost:8195, so the client you would have sent an album link to gets nothing and your own phone cannot reach it either: what you have is a private catalogue of a library that lives on one machine. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.