# Can I self-host Flickr Pro?

**YES** — it's called Piwigo. ONE EVENING setup · ~1.5 hours to running · 1 GB RAM minimum · $6.83/mo you stop paying ($81.96/yr on the Annual plan plan).

Piwigo authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/flickr-pro/

## 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 Piwigo 16.4.0 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, and it is the address every album link and every photo URL
they hand out will carry, so a gallery people have bookmarked is expensive to move.

Piwigo and its database need 1024 MB of RAM available and 10 GB free on /srv: the install plus
room for the first photos and the resized copies Piwigo makes from each one, not a library. Both
images publish amd64 and arm64. Measure all four:

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

If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name nobody resolves.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/piwigo /srv/piwigo/backups /srv/piwigo/gallery
sudo install -d -m 700 /srv/piwigo/mariadb
ls -la /srv/piwigo
```

Assert: `backups` and `gallery` 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. `gallery` is the whole of Piwigo on disk: the PHP tree the image copies in, the
config the installer writes under `local/config`, and every photo uploaded after.

## 3. Secrets

Two secrets: the `piwigo` database user's password and the MariaDB root password. Piwigo ships no
account and no admin token, so the webmaster is created by the user in the browser in step 7.
Print neither value, and keep both out of your summary and every log line.

```bash
umask 077
cat > /srv/piwigo/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PIWIGO_UID=%s\nPIWIGO_GID=%s\n' "$(id -u)" "$(id -g)" >> /srv/piwigo/.env
chmod 600 /srv/piwigo/.env
umask 022
ls -l /srv/piwigo/.env
```

Assert: mode `-rw-------` and the login user's name twice. Compose reads this file for the
`${...}` substitutions in compose.yml and never mounts it into a container. `DB_PASSWORD` is read
back once, in step 7, and nothing else ever needs it.

## 4. compose.yml

```bash
cat > /srv/piwigo/compose.yml <<'EOF'
# Piwigo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://piwigo.org/guides/install/docker
#   requirements ..... https://piwigo.org/guides/install/requirements
#   image README ..... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   image init ....... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, built from github.com/Piwigo/piwigo-docker:
# Alpine with nginx and php-fpm, tagged 16.4.0a for Piwigo 16.4.0. Two services:
# Piwigo, and the MariaDB holding albums, tags, permissions and every photo's
# metadata. The PHP tree and the photo files share /srv/piwigo/gallery, which the
# image populates on first start. Upstream also mounts a scripts directory that
# runs shell code as root inside the container; this file leaves it out. Every
# ${...} comes from /srv/piwigo/.env, mode 600, which Compose reads and never
# mounts. Digests read 2026-08-07; 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: piwigo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: piwigo
      MARIADB_USER: piwigo
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/piwigo/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.

  piwigo:
    image: piwigo/piwigo:16.4.0a@sha256:0ec6f159a3f972338b64e299d56ac37c442dd26cbeec39320d76ea826b5e0b84
    container_name: piwigo
    restart: unless-stopped
    environment:
      # The image's init reads TZ with `set -u`, so it is never left unset.
      TZ: Etc/UTC
      # The gallery tree is chowned to these on every start, so the login
      # user can read the photos without sudo.
      PIWIGO_UID: "${PIWIGO_UID}"
      PIWIGO_GID: "${PIWIGO_GID}"
    volumes:
      # One directory: the release the image ships, the config the installer
      # writes to local/config, and every photo uploaded afterwards.
      - /srv/piwigo/gallery:/var/www/html/piwigo
    healthcheck:
      # / answers 302 to install.php before the installer runs and 200 after.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8159, and the container
      # listens on 80 with nginx running as its own unprivileged user.
      - "127.0.0.1:8159:80"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/piwigo && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. No database port is published and no credential is written
here; every value arrives from .env.

## 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-piwigo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Piwigo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://piwigo.org/guides/install/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. Piwigo decides
# whether the absolute links it prints say https by reading X-Forwarded-Proto,
# and reverse_proxy sets that header on every request without being asked.

<DOMAIN> {
	encode zstd gzip

	# Piwigo serves its own pages and the photo files. These four are the
	# headers a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

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

Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-piwigo, 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 box Prompt Zero configured:

```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. 8159 is bound to 127.0.0.1 and 3306 is never published, so neither has a host port a rule
could apply to. Upstream warns Docker writes its own rules ahead of the firewall's, which is why
8159 is on loopback rather than open and filtered. Assert: `Status: active`, rules for 80,
443/tcp and 443/udp, nothing else.

## 7. Start and verify

The first start copies about 60 MB of PHP into /srv/piwigo/gallery and chowns every file in it.
Give it a minute before concluding anything.

```bash
cd /srv/piwigo
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>/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/install.php | grep -c 'Start Install'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert all three, printing what you received. The loop ends on `200`, the grep prints `1`, and
the last prints `302`, because Piwigo sends every page to install.php until the installer has run.
If any misses, stop, run `docker compose logs --tail 40 piwigo` and
`docker compose logs --tail 20 db`, and name the likely step: a database that never reports
healthy is step 2, a lasting `502` is step 5. A running container is not success.

The first screen at https://<DOMAIN>/install.php shows the heading
`Version 16.4.0 - Installation` above three boxes, `Basic configuration`, `Database configuration`
and `Admin configuration`, with a `Start Install` button underneath.

STOP: tell the user to open https://<DOMAIN>/install.php, fill the form and press Start Install,
and wait. Do not continue until they confirm. Give them these values and nothing else. Host `db`,
User `piwigo`, Database name `piwigo`, Database table prefix `piwigo_` left alone. The password
they fetch themselves with `sudo grep DB_PASSWORD /srv/piwigo/.env`, and it goes in the database
box, not the admin box. In the admin box they choose their own username, password and email; that
account is this gallery's only credential, so it goes in their password manager before they press
the button. Tell them to untick `Send my connection settings by email`: nothing here relays mail,
so that message is not a copy of anything.

Once they confirm, close the door the installer leaves open and prove it is shut:

```bash
cd /srv/piwigo
docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' <<'EOF'
UPDATE piwigo_config SET value = 'false' WHERE param = 'allow_user_registration';
EOF
curl -sS https://<DOMAIN>/install.php
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register.php
curl -sS 'https://<DOMAIN>/ws.php?format=json&method=pwg.getVersion'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert all four, printing what you received for each. install.php now answers exactly
`Piwigo is already installed`, so nobody who finds that URL gets a second setup form.
register.php answers `403`, the security assert here: Piwigo ships with open sign-up on, and a
public gallery that lets strangers create accounts is not what the user asked for. The web API
returns `{"stat":"ok","result":"16.4.0"}`, PHP talking to MariaDB and back. The last prints `200`,
the gallery itself. If the register check prints anything but `403`, stop and say so rather than
reporting success.

## 8. First backup and restore

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

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

Assert: both exist, both are non-empty, both sizes printed. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database. `gallery/_data` is left out on purpose:
it holds the resized copies Piwigo rebuilds on demand from the originals, and on a real library it
is the largest thing in the tree and the only disposable one.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/piwigo/mariadb /srv/piwigo/gallery`, recreate
both as step 2 does, untar the file archive into /srv/piwigo so `.env` and the gallery 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`. Tell the user why that order matters: MariaDB reads its password from
`.env` when it initialises an empty directory, and `gallery/local/config/database.inc.php` is what
tells Piwigo it has already been installed. Restore one without the other and they land back on
the installer.

## 9. Updating later

Versions are listed at https://github.com/Piwigo/Piwigo/releases and the image tags carrying them
at https://hub.docker.com/r/piwigo/piwigo/tags. Take both backups first, then edit the piwigo
image line in /srv/piwigo/compose.yml to the new tag and digest:

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

The image compares the version it ships against the one in the gallery directory and copies the
newer files over, so the log prints `Updating to piwigo version` and the number. Piwigo then asks
for its database upgrade at the next administrator sign-in. Re-run step 7's `pwg.getVersion`
check afterwards and confirm the number moved.

## 10. What will probably go wrong

The gallery is public the moment the installer finishes, and that surprised me. Piwigo ships with
guest browsing on, the right default for the thing Flickr sold and the wrong one if you assumed a
private server meant a private gallery. Nothing here changes it, because the fix is editorial: in
Administration an album is set private and access granted to named users or groups, one album at a
time. Decide before the first upload, because a photo that was public for an afternoon was
public.

## 11. Out of scope

- Do not configure SMTP or a mail relay. The gallery works without mail; what mail buys is
  password reset and comment notification, and that is a second install to do properly.
- Do not turn user registration back on to let friends comment. Step 7 closed it deliberately;
  Piwigo creates accounts for named people in Administration instead.
- Do not install plugins or themes from the Piwigo extension gallery yet. Each writes into the
  directory the image overwrites on upgrade, and this install has one backup.
- Do not mount a scripts directory or set up an FTP synchronise path. Both add a way in that this
  prompt neither backs up nor checks.
````

## 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 Piwigo 16.4.0 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>` is the address every album link and every photo URL you
hand out will carry. Piwigo does not force you to keep it, but a gallery other people have
bookmarked is expensive to move, so pick the hostname you intend to keep.

## 1. Preflight

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

You should see: at least `1024` MB available, at least `10` G free, `amd64` or `arm64`, 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,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. The 10 GB is the install
plus room for the first photos and the resized copies Piwigo makes from each one; a real library
needs whatever that library weighs, and a photo gallery is the one app on this site where the
disk line on your invoice is the line that matters.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/piwigo /srv/piwigo/backups /srv/piwigo/gallery
sudo install -d -m 700 /srv/piwigo/mariadb
ls -la /srv/piwigo
```

You should see: `backups` and `gallery` 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 one you have already chowned to yourself makes it refuse
to initialise. `gallery` is the whole of Piwigo on disk: the PHP tree the image copies in on first
start, the config the installer writes under `local/config`, and every photo you upload after.

## 3. Secrets

Two secrets, both generated here on the server and both written straight into a file only you can
read: the password for the `piwigo` database user, and the MariaDB root password. Piwigo ships no
account of its own, so the webmaster is the one you create in the browser in step 7.

```bash
umask 077
cat > /srv/piwigo/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PIWIGO_UID=%s\nPIWIGO_GID=%s\n' "$(id -u)" "$(id -g)" >> /srv/piwigo/.env
chmod 600 /srv/piwigo/.env
umask 022
ls -l /srv/piwigo/.env
```

You should see: mode `-rw-------`, your own username twice, 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 separately in different shells. Run `chmod 600 /srv/piwigo/.env` and carry on. If
the file already existed from an earlier attempt, this block has now overwritten both secrets,
which is fine before the database exists and a problem afterwards: MariaDB keeps the password it
was created with, so a changed `DB_PASSWORD` on an existing directory produces an access-denied
error rather than anything that mentions passwords.

Do not paste that file, either secret, or any output containing them into this chat window. You
will read `DB_PASSWORD` once in step 7 to type it into the installer form in your browser, and it
goes from the terminal to the browser and nowhere else.

## 4. compose.yml

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

```bash
cat > /srv/piwigo/compose.yml <<'EOF'
# Piwigo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://piwigo.org/guides/install/docker
#   requirements ..... https://piwigo.org/guides/install/requirements
#   image README ..... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   image init ....... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, built from github.com/Piwigo/piwigo-docker:
# Alpine with nginx and php-fpm, tagged 16.4.0a for Piwigo 16.4.0. Two services:
# Piwigo, and the MariaDB holding albums, tags, permissions and every photo's
# metadata. The PHP tree and the photo files share /srv/piwigo/gallery, which the
# image populates on first start. Upstream also mounts a scripts directory that
# runs shell code as root inside the container; this file leaves it out. Every
# ${...} comes from /srv/piwigo/.env, mode 600, which Compose reads and never
# mounts. Digests read 2026-08-07; 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: piwigo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: piwigo
      MARIADB_USER: piwigo
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/piwigo/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.

  piwigo:
    image: piwigo/piwigo:16.4.0a@sha256:0ec6f159a3f972338b64e299d56ac37c442dd26cbeec39320d76ea826b5e0b84
    container_name: piwigo
    restart: unless-stopped
    environment:
      # The image's init reads TZ with `set -u`, so it is never left unset.
      TZ: Etc/UTC
      # The gallery tree is chowned to these on every start, so the login
      # user can read the photos without sudo.
      PIWIGO_UID: "${PIWIGO_UID}"
      PIWIGO_GID: "${PIWIGO_GID}"
    volumes:
      # One directory: the release the image ships, the config the installer
      # writes to local/config, and every photo uploaded afterwards.
      - /srv/piwigo/gallery:/var/www/html/piwigo
    healthcheck:
      # / answers 302 to install.php before the installer runs and 200 after.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8159, and the container
      # listens on 80 with nginx running as its own unprivileged user.
      - "127.0.0.1:8159:80"
    depends_on:
      db:
        condition: service_healthy
EOF
cd /srv/piwigo && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/piwigo/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/piwigo/compose.yml` and paste again in one go. No database port is published here,
and no credential is written in this file; every value arrives from `.env`.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-piwigo
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Piwigo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://piwigo.org/guides/install/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. Piwigo decides
# whether the absolute links it prints say https by reading X-Forwarded-Proto,
# and reverse_proxy sets that header on every request without being asked.

<DOMAIN> {
	encode zstd gzip

	# Piwigo serves its own pages and the photo files. These four are the
	# headers a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8159 is the loopback port compose publishes here, not a container port
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8159
}
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 reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-piwigo /etc/caddy/Caddyfile`, reload, and
paste again. There is no https setting to configure inside Piwigo: it reads `X-Forwarded-Proto`,
which Caddy sets on every proxied request, and that is how the links it prints know they are
https even though the container speaks plain http.

## 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`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8159` or `3306`.

If you do not: delete anything for `8159` or `3306` with `sudo ufw delete allow 8159`. 80/tcp
redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp is
HTTP/3. Piwigo's own image notes warn that Docker writes its own rules ahead of the firewall's,
which is why the compose file binds 8159 to 127.0.0.1 instead of relying on a rule to keep it
shut, and why 3306 is never published at all. `Status: inactive` is a different problem: Prompt
Zero left this firewall enabled, so something has turned it off since, and `sudo ufw enable` puts
it back before you go any further.

## 7. Start and verify

The first start copies about 60 MB of PHP into /srv/piwigo/gallery and chowns every file in it.
Give it a minute before concluding anything.

```bash
cd /srv/piwigo
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>/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/install.php | grep -c 'Start Install'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see, in order: the loop reaching `200`, then `1`, then `302`.

If you do not: the `302` is the one worth understanding. Piwigo sends every page to install.php
until the installer has run, 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 piwigo` second. A lasting
`502` is step 5. A container that says `Up` proves nothing on its own.

The first screen at https://<DOMAIN>/install.php shows the heading `Version 16.4.0 - Installation`
above three boxes, `Basic configuration`, `Database configuration` and `Admin configuration`, with
a `Start Install` button underneath.

Now open that page in your browser and fill the form. Use exactly these values in the database
box: Host `db`, User `piwigo`, Database name `piwigo`, and leave Database table prefix on
`piwigo_`. For the Password field in that box, read the value on the server with

```bash
sudo grep DB_PASSWORD /srv/piwigo/.env
```

and copy it straight into the browser. Do not paste it back here. In the admin box, choose your
own username, password and email address: that account is this gallery's only credential and
there is no password-reset mail, so put it in your password manager before you press the button.
Untick `Send my connection settings by email`, because nothing in this install relays mail to the
outside world and that message is not a copy of anything. Then press `Start Install`.

You should see: a page saying the installation is completed, with a link into the gallery.

If you do not: `Connection to server succeeded, but it was impossible to connect to database` is
the database name or user typed wrong, and a failure on the host line means you typed something
other than `db`. Both are safe to correct and submit again.

Once the installer has finished, close the door it leaves open and prove it is shut:

```bash
cd /srv/piwigo
docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' <<'EOF'
UPDATE piwigo_config SET value = 'false' WHERE param = 'allow_user_registration';
EOF
curl -sS https://<DOMAIN>/install.php
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/register.php
curl -sS 'https://<DOMAIN>/ws.php?format=json&method=pwg.getVersion'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see, in order: exactly `Piwigo is already installed`, then `403`, then
`{"stat":"ok","result":"16.4.0"}`, then `200`.

If you do not: the `403` is the one that matters. Piwigo ships with user registration switched on,
so until that UPDATE runs, anyone who finds https://<DOMAIN>/register.php can make themselves an
account on your gallery. A `200` there means the setting did not change: check that the UPDATE
printed no error, and run the four commands again. `Piwigo is already installed` is Piwigo's own
words, printed by install.php once the config file exists, so seeing it means nobody who finds
that URL gets a second setup form. The API line is PHP talking to MariaDB and back; the last
`200` is the gallery itself, now that the redirect to the installer is gone.

## 8. First backup and restore

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

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

You should see: two files, the dump a few tens of kilobytes and the archive a few tens of
megabytes on a fresh install, because the archive carries Piwigo's own PHP tree along with your
photos. 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 the dump line without `| gzip` to read the error.
`gallery/_data` is excluded on purpose: it holds the resized copies Piwigo rebuilds on demand from
your originals, and on a real library it is the largest thing in the tree and the only disposable
one.

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/piwigo
scp vps:/srv/piwigo/backups/* ~/backups/piwigo/
```

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

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/piwigo
docker compose down
sudo rm -rf /srv/piwigo/mariadb /srv/piwigo/gallery
sudo install -d -m 700 /srv/piwigo/mariadb
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/piwigo/gallery
sudo tar -xzf /srv/piwigo/backups/piwigo-files-$(date +%F).tar.gz -C /srv/piwigo
docker compose up -d db
sleep 30
gunzip -c /srv/piwigo/backups/piwigo-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 20
curl -sS 'https://<DOMAIN>/ws.php?format=json&method=pwg.getVersion'
```

You should see: no output from the restore itself, then `{"stat":"ok","result":"16.4.0"}` from the
last line, which means the gallery came back from a database and a directory that were both
deleted.

If you do not: `Access denied for user 'piwigo'` means the archive did not restore `.env` before
MariaDB initialised its empty directory, so it invented a different password. Repeat the block and
check that the tar step runs before `docker compose up -d db`. That ordering is the whole lesson:
`.env` carries the database password and `gallery/local/config/database.inc.php` is what tells
Piwigo it has already been installed, so a restore missing either one lands you back on the
installer with a gallery full of orphaned files.

## 9. Updating later

Versions are listed at https://github.com/Piwigo/Piwigo/releases and the image tags carrying them
at https://hub.docker.com/r/piwigo/piwigo/tags. Take both backup artifacts first, then edit the
piwigo `image:` line in /srv/piwigo/compose.yml to the new tag and its digest.

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

You should see: `Updating to piwigo version` followed by the new number, then nginx and php-fpm
starting, and no repeating restart.

If you do not: `Current piwigo version` and the old number means the pull did not take, so check
the digest you pasted. Put the old tag and digest back if anything looks wrong, run the same three
commands, and re-run step 7's `pwg.getVersion` check before you call the update done. Piwigo asks
for its own database upgrade the first time an administrator signs in after a version bump; say
yes to it in the browser.

## 10. What will probably go wrong

The gallery is public the moment the installer finishes, and that surprised me. Piwigo ships with
guest browsing on, which is the right default for the thing Flickr sold and the wrong one if you
assumed a private server meant a private gallery. Nothing in this install changes it, because the
fix is editorial rather than operational: in Administration an album is set private and access
granted to named users or groups, one album at a time. Decide that before your first upload, not
after, because a photo that was public for an afternoon was public.

## 11. Out of scope

- Do not configure SMTP or a mail relay. The gallery works without mail; what mail buys is
  password reset and comment notification, and that is a second install to do properly.
- Do not turn user registration back on to let friends comment. Step 7 closed it deliberately;
  Piwigo creates accounts for named people in Administration instead.
- Do not install plugins or themes from the Piwigo extension gallery yet. Each writes into the
  directory the image overwrites on upgrade, and this install has one backup.
- Do not mount a scripts directory or set up an FTP synchronise path. Both add a way in that this
  install neither backs up nor checks.
````

## 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 Piwigo 16.4.0, with the MariaDB it keeps albums and photo metadata in, under
~/selfhost/piwigo, answering at http://localhost:8159.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install. Piwigo is a gallery
you publish, and this one answers at http://localhost:8159, which means "this computer" wherever it
is read: an album link sent to family opens nothing. They get a private catalogue of their own
library, with albums, tags and search.

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. Piwigo plus MariaDB needs 1024 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/piwigo/gallery ~/selfhost/piwigo/backups
ls -la ~/selfhost/piwigo
```

Assert: `gallery` and `backups`, owned by the user. `gallery` is the whole of Piwigo on disk: the
PHP tree the image copies in, the config the installer writes under `local/config`, and every photo
uploaded after.

## 4. Secrets

Two secrets: the `piwigo` database user's password and the MariaDB root password. Piwigo ships no
account and no admin token; the webmaster is created in the browser in step 7. Print neither
value, and keep both out of your summary and every log line.

```bash
umask 077
cat > ~/selfhost/piwigo/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
printf 'PIWIGO_UID=%s\nPIWIGO_GID=%s\n' "$(id -u)" "$(id -g)" >> ~/selfhost/piwigo/.env
chmod 600 ~/selfhost/piwigo/.env
umask 022
ls -l ~/selfhost/piwigo/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so these run the same on all three, and
`DB_PASSWORD` is read back once in step 7. On Windows the mode bits are advisory and the boundary
is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/piwigo/compose.yml <<'EOF'
# Piwigo · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ... https://piwigo.org/guides/install/docker
#   image README ..... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   image init ....... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, built from github.com/Piwigo/piwigo-docker:
# Alpine with nginx and php-fpm, tagged 16.4.0a for Piwigo 16.4.0. Paths are
# relative to ~/selfhost/piwigo/, so one file works on all three systems. The
# database is a named volume because MariaDB chowns its data directory to a uid
# Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: piwigo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: piwigo
      MARIADB_USER: piwigo
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - piwigo-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.

  piwigo:
    image: piwigo/piwigo:16.4.0a@sha256:0ec6f159a3f972338b64e299d56ac37c442dd26cbeec39320d76ea826b5e0b84
    container_name: piwigo
    restart: unless-stopped
    environment:
      # The image's init reads TZ with `set -u`, so it is never left unset.
      TZ: Etc/UTC
      # On macOS and Windows the image cannot set an ACL on a bind mount and
      # falls back to chmod, warning as it does; that is expected.
      PIWIGO_UID: "${PIWIGO_UID}"
      PIWIGO_GID: "${PIWIGO_GID}"
    volumes:
      # The release the image ships, the config the installer writes, the photos.
      - ./gallery:/var/www/html/piwigo
    healthcheck:
      # / answers 302 to install.php before the installer runs and 200 after.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8159.
      - "127.0.0.1:8159:80"
    depends_on:
      db:
        condition: service_healthy

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

Assert: that prints `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 published beyond loopback to close. Browsers treat http://localhost as a secure
context anyway, so pages needing crypto still work.

8159 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 the point of it. Confirm:

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

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

## 7. Start and verify

The first start copies about 60 MB of PHP into gallery/ and chowns every file. Give it a minute.

```bash
cd ~/selfhost/piwigo
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:8159/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8159/install.php | grep -c 'Start Install'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8159/
```

Assert all three, printing what you received. The loop ends on `200`, the grep prints `1`, and the
last prints `302`, because Piwigo sends every page to install.php until the installer has run. On a
miss, stop and run `docker compose logs --tail 40 piwigo` and `docker compose logs --tail 20 db`: a
database never reporting healthy is step 4, `port is already allocated` is step 10. A running
container is not success.

The first screen at http://localhost:8159/install.php shows the heading
`Version 16.4.0 - Installation` above three boxes, `Basic configuration`, `Database configuration`
and `Admin configuration`, and a `Start Install` button.

STOP: tell the user to open http://localhost:8159/install.php, fill the form and press Start
Install, and wait. Do not continue until they confirm. Give them these values and nothing else.
Host `db`, User `piwigo`, Database name `piwigo`, prefix `piwigo_` left alone. The password they
fetch with `grep DB_PASSWORD ~/selfhost/piwigo/.env`, into the database box, not the admin box. In
the admin box they pick their own username, password and email; that is this gallery's only
credential, so it goes in their password manager first. Have them untick
`Send my connection settings by email`, because nothing here relays mail.

Once they confirm, close the door the installer leaves open and prove it is shut:

```bash
cd ~/selfhost/piwigo
docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' <<'EOF'
UPDATE piwigo_config SET value = 'false' WHERE param = 'allow_user_registration';
EOF
curl -sS http://localhost:8159/install.php
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8159/register.php
curl -sS 'http://localhost:8159/ws.php?format=json&method=pwg.getVersion'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8159/
```

Assert all four, printing what you received. install.php answers exactly
`Piwigo is already installed`, so that URL is no longer a setup form. register.php answers `403`:
Piwigo ships with open sign-up on and this install does not want it. The web API returns
`{"stat":"ok","result":"16.4.0"}`, PHP talking to MariaDB and back. The last prints `200`.

## 8. First backup and restore

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

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

Assert: both exist, both non-empty, both sizes printed. Nothing goes offline:
`--single-transaction` snapshots a running InnoDB database. `gallery/_data` is left out because
Piwigo rebuilds those resized copies from the originals on demand, and on a real library they are
the largest disposable thing in the tree.

Both archives 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 the user for a destination that leaves this computer, a sync
folder or an external drive, and copy both there with `cp`; in Git Bash a Windows drive is `/d/...`.
Assert: both filenames are listed there, or this install has no backup.

To restore: untar the file archive into ~/selfhost/piwigo first, so `.env` and the gallery are back
before any container starts. MariaDB reads `DB_PASSWORD` from `.env` when it initialises an empty
volume, and `gallery/local/config/database.inc.php` tells Piwigo it is installed. Then
`docker compose down -v`, `docker compose up -d db`, wait 30 seconds, 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` and open an album.

## 9. Updating later

Versions are listed at https://github.com/Piwigo/Piwigo/releases and the image tags at
https://hub.docker.com/r/piwigo/piwigo/tags. Back up first, then edit the piwigo image line to the
new tag and digest:

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

The image compares the version it ships against the one in gallery/ and copies the newer files
over, so the log prints `Updating to piwigo version` and the number. Re-run step 7's check.

## 10. What will probably go wrong

I closed the lid, came back next morning, opened http://localhost:8159 and got a connection error
that reads like a lost library. Nothing was lost: Docker Desktop had not started with the session,
so nothing was listening on 8159, and `restart: unless-stopped` acts only once the daemon is up.
Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/piwigo && docker compose up -d` before concluding anything is broken. The other
candidate is 8159 already taken: `lsof -nP -iTCP:8159 -sTCP:LISTEN` finds it.

## 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 8159 to 0.0.0.0 so a phone can reach it. Piwigo's shipped default lets a visitor
  browse without an account, so that publishes the gallery to every network the user joins.
- Do not turn registration back on, and do not configure SMTP. Mail from a laptop that sleeps is
  a queue rather than a delivery.
- Do not install plugins or themes yet. Each writes into the directory the image overwrites on
  upgrade.
````

## docker-compose.yml

```yaml
# Piwigo · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ... https://piwigo.org/guides/install/docker
#   requirements ..... https://piwigo.org/guides/install/requirements
#   image README ..... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   image init ....... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, built from github.com/Piwigo/piwigo-docker:
# Alpine with nginx and php-fpm, tagged 16.4.0a for Piwigo 16.4.0. Two services:
# Piwigo, and the MariaDB holding albums, tags, permissions and every photo's
# metadata. The PHP tree and the photo files share /srv/piwigo/gallery, which the
# image populates on first start. Upstream also mounts a scripts directory that
# runs shell code as root inside the container; this file leaves it out. Every
# ${...} comes from /srv/piwigo/.env, mode 600, which Compose reads and never
# mounts. Digests read 2026-08-07; 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: piwigo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: piwigo
      MARIADB_USER: piwigo
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/piwigo/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.

  piwigo:
    image: piwigo/piwigo:16.4.0a@sha256:0ec6f159a3f972338b64e299d56ac37c442dd26cbeec39320d76ea826b5e0b84
    container_name: piwigo
    restart: unless-stopped
    environment:
      # The image's init reads TZ with `set -u`, so it is never left unset.
      TZ: Etc/UTC
      # The gallery tree is chowned to these on every start, so the login
      # user can read the photos without sudo.
      PIWIGO_UID: "${PIWIGO_UID}"
      PIWIGO_GID: "${PIWIGO_GID}"
    volumes:
      # One directory: the release the image ships, the config the installer
      # writes to local/config, and every photo uploaded afterwards.
      - /srv/piwigo/gallery:/var/www/html/piwigo
    healthcheck:
      # / answers 302 to install.php before the installer runs and 200 after.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: the host's Caddy alone reaches 8159, and the container
      # listens on 80 with nginx running as its own unprivileged user.
      - "127.0.0.1:8159:80"
    depends_on:
      db:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Piwigo · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ... https://piwigo.org/guides/install/docker
#   image README ..... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   image init ....... https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, built from github.com/Piwigo/piwigo-docker:
# Alpine with nginx and php-fpm, tagged 16.4.0a for Piwigo 16.4.0. Paths are
# relative to ~/selfhost/piwigo/, so one file works on all three systems. The
# database is a named volume because MariaDB chowns its data directory to a uid
# Docker Desktop cannot grant on a home bind mount. Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
    container_name: piwigo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: piwigo
      MARIADB_USER: piwigo
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - piwigo-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.

  piwigo:
    image: piwigo/piwigo:16.4.0a@sha256:0ec6f159a3f972338b64e299d56ac37c442dd26cbeec39320d76ea826b5e0b84
    container_name: piwigo
    restart: unless-stopped
    environment:
      # The image's init reads TZ with `set -u`, so it is never left unset.
      TZ: Etc/UTC
      # On macOS and Windows the image cannot set an ACL on a bind mount and
      # falls back to chmod, warning as it does; that is expected.
      PIWIGO_UID: "${PIWIGO_UID}"
      PIWIGO_GID: "${PIWIGO_GID}"
    volumes:
      # The release the image ships, the config the installer writes, the photos.
      - ./gallery:/var/www/html/piwigo
    healthcheck:
      # / answers 302 to install.php before the installer runs and 200 after.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1/ || exit 1"]
      start_period: 60s
      interval: 15s
      retries: 20
    ports:
      # Loopback only: no other device on the wifi reaches 8159.
      - "127.0.0.1:8159:80"
    depends_on:
      db:
        condition: service_healthy

volumes:
  piwigo-dbdata:
```

## Caddyfile

```text
# Piwigo · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://piwigo.org/guides/install/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. Piwigo decides
# whether the absolute links it prints say https by reading X-Forwarded-Proto,
# and reverse_proxy sets that header on every request without being asked.

<DOMAIN> {
	encode zstd gzip

	# Piwigo serves its own pages and the photo files. These four are the
	# headers a reverse proxy is the right place for.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

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

## install.sh

```bash
#!/usr/bin/env bash
# Piwigo · 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://piwigo.org/guides/install/docker
#   https://piwigo.org/guides/install/requirements
#   https://github.com/Piwigo/piwigo-docker/blob/v16.4a/README.md
#   https://github.com/Piwigo/piwigo-docker/blob/v16.4a/config/init-script.sh
#
# The image is the Piwigo project's own, pinned by tag and digest.
#
# Two secrets are generated here, on this machine: the piwigo database user's
# password and the MariaDB root password. Both go into /srv/piwigo/.env with
# mode 600 and neither is ever printed. Piwigo ships no account of its own, so
# the webmaster is created by you in the browser installer this script leaves
# waiting, and the closing notes list the checks to run once you have.
#
# DOMAIN_HOST is the address every album link and photo URL will carry.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/piwigo}"
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 1024 ] || die "only ${avail_mb} MB of RAM available; PHP plus MariaDB wants 1024 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"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -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. gallery is the whole of
# Piwigo on disk, application tree and photos together.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups" "$APP_DIR/gallery"
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 two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: one is typed into a browser form and the
# other travels in a connection string, and neither wants escaping. Read the
# database password later with
#   sudo grep DB_PASSWORD /srv/piwigo/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DB_PASSWORD=$(openssl rand -hex 32)
		MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	printf 'PIWIGO_UID=%s\nPIWIGO_GID=%s\n' "$(id -u)" "$(id -g)" >> "$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-piwigo"
	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 8159 nor 3306 is one of them ------------
#
# Upstream's image notes warn that Docker writes its own rules ahead of the
# firewall's, which is why 8159 is bound to 127.0.0.1 in compose.yml rather
# than left open and filtered here.

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8159 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 copies about 60 MB of PHP into $APP_DIR/gallery and chowns
# every file in it, so the wait loop below is generous on purpose.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/install.php" | grep -q 'Start Install' \
	|| die "install.php answered 200 without the Start Install button. Check: docker compose logs --tail 40 piwigo"

# Before the installer runs, Piwigo sends every other page to install.php.
root_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$root_code" = "302" ] || die "the site root answered ${root_code}, not the 302 an uninstalled Piwigo sends. Stop and investigate."

# --- 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 dump is a schemaless MariaDB at this point; the
# archive already carries .env, the Piwigo 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/piwigo-db-${STAMP}.sql.gz"
sudo tar --exclude='gallery/_data' -czf "$APP_DIR/backups/piwigo-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env gallery -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/piwigo-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/piwigo-files-${STAMP}.tar.gz" ] || die "the file archive is empty"

cat <<-DONE

	Piwigo is serving its installer at https://${DOMAIN_HOST}/install.php

	  1. Open that page now and fill the form. In the database box use Host db,
	     User piwigo, Database name piwigo, and leave the table prefix on piwigo_.
	     Read the password with
	       sudo grep DB_PASSWORD $APP_DIR/.env
	     and copy it into that box. It was not printed here. In the admin box
	     choose your own username, password and email: that account is this
	     gallery's only credential and no mail is relayed from this install, so
	     put it in your password manager before you press Start Install. Untick
	     "Send my connection settings by email" for the same reason.
	  2. Then close the door the installer leaves open. Piwigo ships with user
	     registration switched on, so until you run this anyone who finds
	     https://${DOMAIN_HOST}/register.php can create an account on your gallery:
	       cd $APP_DIR && docker compose exec -T db sh -c 'exec mariadb -u"\$MARIADB_USER" -p"\$MARIADB_PASSWORD" -e "UPDATE piwigo_config SET value = '"'"'false'"'"' WHERE param = '"'"'allow_user_registration'"'"'" "\$MARIADB_DATABASE"'
	       curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/register.php
	     That must print 403. This is the security check the script cannot run
	     for you, because the table does not exist until the installer has run.
	  3. Prove the install is real and the setup form is gone:
	       curl -sS https://${DOMAIN_HOST}/install.php
	       curl -sS 'https://${DOMAIN_HOST}/ws.php?format=json&method=pwg.getVersion'
	     The first must print exactly "Piwigo is already installed", the second
	     {"stat":"ok","result":"16.4.0"}.
	  4. Your gallery is public. Piwigo ships with guest browsing on, so anyone
	     with the address can see every album you have not made private. Set
	     album permissions in Administration before your first upload.
	  5. First backup written to $APP_DIR/backups: a database dump and a file
	     archive holding .env, the whole gallery tree and the live Caddy site
	     block. Take them again after the installer has run, because this pair
	     predates your account. They are on the same disk as the data, which is
	     not a backup. Copy them off the box tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/piwigo/

DONE
```

## Also evaluated

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

- **Immich** — Your camera roll uploads itself to a server you own, and search still finds the dog on the beach. Second here, and first for a different question. If what you want back is the camera roll backing itself up from your phone the moment you take a photo, with faces, places and a scrolling timeline, Immich is that page and Piwigo is not. It is a heavier install and it points inward: a private archive of everything you shoot, rather than a curated gallery built for other people to look at.

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