🖼️

Nextcloud/PhotoPrismでフォトストレージ(オンプレミス)を構築する

2023/05/05に公開

🚀始めに

友人と遊んだり旅行に行ったりして写真を撮影したとき、画像データをGoogleフォトに同期していたのですが、Googleフォトの容量が無制限ではなくなったこともあり、代替手段を探していました。
Amazonフォトを利用するのが一番楽で良さそうなのですが、最近Dockerにハマっていることもあり、折角だからDockerでフォトストレージを構築しようと思い挑戦してみました。
「外出先で写真撮影して、ホテルのWi-Fiに接続したら、画像データがフォトストレージに同期される」ことを目標としています。
Googleフォトの代替手段を探している方、Dockerを利用して何か作ってみたい方の参考になれば嬉しいです。

💻マシンスペック

今回利用したマシンのスペックは次の通りです。
Orange Pi 5
SoC: Rockchip RK3588S
メモリ: 16GB
ストレージ: MicroSD 512GB
OS: Armbian 23.02 Jammy Gnome
その他: Docker CE(Community Edition) 23.0.5
http://www.orangepi.org/html/hardWare/computerAndMicrocontrollers/details/Orange-Pi-5.html
https://www.armbian.com/orangepi-5/
※今回はarm64マシンで構築しましたが、一般的なamd64マシンでも問題なく構築できると思います。

🗺システム構成

今回作成したフォトストレージのシステム構成は次の通りです。
システム構成
システム構成
※一部のアイコンセットにICONS8のアイコンセットを利用しています。
https://icons8.jp/

🌏OSS/サービス

今回利用したOSS/サービスの概要は次の通りです。
・Docker
コンテナ型仮想環境のプラットフォーム。
https://www.docker.com/
・Nextcloud
オンラインストレージとして利用できるOSS。
Googleドライブみたいなやつです。
https://nextcloud.com/
・PhotoPrism
画像管理プラットフォームとして利用できるOSS。
Googleフォトみたいなやつです。
https://www.photoprism.app/
・Traefik
リバースプロキシとして利用できるOSS。
Nginxでも代用とできると思います。
https://traefik.io/traefik/
・Googleドメイン
ドメインレジストラ。
今回はdevドメインが欲しくて利用しましたが、別のサービスを利用しても問題ありません。
https://domains.google/intl/ja_jp/
・MyDNS
無料で利用できるDDNSサービス。
グローバルIPアドレスや各種レコードの登録、SSL証明書の発行など、何かとお世話になります。
https://www.mydns.jp/
・Let's Encrypt
無料で利用できるSSL証明書発行サービス。
3ヶ月で証明書が失効するため、更新する手段を別途用意します。
https://letsencrypt.org/ja/
・Certbot
SSL証明書を発行するOSS。
Certbotを利用してLet's EncryptでSSL証明書を発行します。
https://certbot.eff.org/

🛠構築手順

  1. レジストラでドメインを取得する
    Googleドメインなどのレジストラでドメインを取得します。
    ※例としてexample.devを取得したことにします。
    GoogleドメインのDNSサーバーをそのまま利用することも可能ですが、後述するグローバルIPアドレスの更新やSSL証明書の発行でMyDNSと連携するので、今回はDNSサーバーにMyDNSのDNSサーバーを指定します。
    レジストラの設定
    レジストラの設定
    これで、全体構成の「1. 名前解決」ができるようになります。
    (と言っても、GoogleドメインはMyDNSを参照してね、と応答するだけですが…。)

  2. MyDNSにドメイン情報を設定する
    まず、グローバルIPアドレスを登録(更新)します。
    MyDNSでは様々な方法でグローバルIPアドレスを登録することができますが、今回はHTTPのBASIC認証で登録します。
    設定項目と設定値は次の通りです。

    設定項目 設定値
    通知先URL https://ipv4.mydns.jp/login.html
    https://ipv6.mydns.jp/login.html
    アカウント MyDNSのID
    パスワード MyDNSのパスワード

    今回はHTTPのBASIC認証でグローバルIPアドレスを登録するためのシェルスクリプトを作成しました。

    update_mydns.sh
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}); pwd)
    
    cd ${SCRIPT_DIRNAME}
    
    # Update MyDNS
    curl -sLv -u <MyDNSのID>:<MyDNSのパスワード> https://ipv4.mydns.jp/login.html
    curl -sLv -u <MyDNSのID>:<MyDNSのパスワード> https://ipv6.mydns.jp/login.html
    

    グローバルIPアドレスはルーターの再起動などで変わってしまうので、定期的に更新する(MyDNSに通知する)と良いです。
    今回は前述のシェルスクリプトを定期実行するようcronを設定しました。

    次に、名前解決したいサブドメインを登録します。
    例えば、www.example.dev を名前解決したい場合は、Hostnameにwwwを指定します。
    今回は特にこだわりがなかったので、ワイルドカード(*)を指定しました。
    ドメイン情報
    ドメイン情報
    これで、全体構成の「2. 名前解決」および「7. IPアドレス更新」ができるようになります。

  3. SSL証明書を発行する
    Let's EncryptからSSL証明書を発行してもらいます。
    証明書を発行するために、Certbotを利用します。
    また、今回は一般的なHTTPチャレンジではなくDNSチャレンジを利用しました。
    (ワイルドカード証明書を発行したかったため。)
    チャレンジの種類や証明書の種類については次を参照してください。
    https://letsencrypt.org/ja/docs/challenge-types/
    https://kinsta.com/jp/blog/types-of-ssl-certificates/#ssl-3

    DNSチャレンジを利用してSSL証明書を発行する場合は、ワンタイムパスワードをTXTレコードとしてDNSサーバーに登録する必要があります。
    MyDNSでは、ワンタイムパスワードの登録/削除を自動化するスクリプトが用意されています。
    https://github.com/disco-v8/DirectEdit/
    今回はCertbotのコンテナをビルドして、コンテナ起動時に前述のスクリプトをマウントしてSSL証明書を発行できるようにしました。
    ディレクトリ構成は次の通りです。

    <PROJECT_NAME>
    ├── .dockerignore
    ├── docker
    │   └── certbot.dockerfile   ★dockerfile
    ├── script
    │   ├── build_image.sh   ★Certbotのコンテナをビルドするシェルスクリプト
    │   ├── run_certbot_wildcard.sh   ★SSL証明書を発行するシェルスクリプト
    │   └── update_mydns.sh   ★グローバルIPアドレスを更新するシェルスクリプト
    └── volume
        └── certbot
    	 └── workspace   ★コンテナにマウントするディレクトリ
    	    ├── certificate
    	    │   └── wildcard   ★SSL証明書を配置するディレクトリ
    	    ├── hook
    	    │   └── wildcard
    	    │       └── DirectEdit-master   ★MyDNSの自動化ツール
    	    │           ├── README.md
    	    │           ├── debug.log
    	    │           ├── txtdelete.php
    	    │           ├── txtedit.conf
    	    │           └── txtregist.php
    	    └── script
    		└── run_certbot_wildcard.sh   ★SSL証明書を発行するシェルスクリプト
    

    まず、Certbotのコンテナをビルドします。

    certbot.dockerfile
    # ==============================
    # Stage-1
    # ==============================
    FROM ubuntu:jammy as ubuntu-jammy-user
    
    ARG DEBIAN_FRONTEND=noninteractive
    ARG USER_NAME=nobody
    ARG GROUP_NAME=nogroup
    ARG PASSWORD=${USER_NAME}
    ARG USER_ID=1000
    ARG GROUP_ID=${USER_ID}
    
    SHELL ["/usr/bin/bash", "-c"]
    
    # ==============================
    # Update apt config
    # ==============================
    RUN echo "Update apt config" && \
        echo "APT::Install-Recommends 0;" > /etc/apt/apt.conf.d/00-no-install-recommends && \
        echo "APT::Install-Suggests 0;" > /etc/apt/apt.conf.d/00-no-install-suggests
    
    # ==============================
    # Update package lists
    # Upgrade packages
    # ==============================
    RUN echo "Upgrade packages" && \
        apt update && \
        apt upgrade -y && \
        apt autopurge -y
    
    # ==============================
    # Add general user
    # ==============================
    RUN echo "Add general user" && \
        if [ "${USER_NAME}" = "nobody" ]; \
        then \
    	echo "Skip adding general user"; \
        else \
    	apt install -y sudo && \
    	groupadd -g ${GROUP_ID} ${GROUP_NAME} && \
    	useradd -u ${USER_ID} -g ${GROUP_ID} --groups sudo -s /usr/bin/bash ${USER_NAME} && \
    	echo ${USER_NAME}:${PASSWORD} | chpasswd && \
    	echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers; \
        fi;
    
    
    # ==============================
    # Stage-2
    # ==============================
    FROM ubuntu-jammy-user as certbot
    
    SHELL ["/usr/bin/bash", "-c"]
    
    # ==============================
    # Install certbot
    # ==============================
    RUN echo "Install certbot" && \
        apt install -y certbot \
    		   php \
    		   php-mbstring
    
    # ==============================
    # Remove unused packages
    # ==============================
    RUN echo "Remove unused packages" && \
        apt update && \
        apt upgrade -y && \
        apt autopurge -y && \
        apt autoclean && \
        rm -rf /var/lib/apt/lists/*
    
    USER ${USER_NAME}
    
    build_image.sh
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    DOCKER_PROJECT_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    cd ${DOCKER_PROJECT_PATH}
    
    docker image build -t certbot \
    		   -f ${DOCKER_PROJECT_PATH}/docker/certbot.dockerfile . \
    		   --progress plain \
    		   --build-arg USER_NAME=$(id -nu ${USER}) \
    		   --build-arg GROUP_NAME=$(id -ng ${USER}) \
    		   --build-arg USER_ID=$(id -u ${USER}) \
    		   --build-arg GROUP_ID=$(id -g ${USER})
    docker system prune -f
    

    次に、MyDNSの自動化ツールの使い方に従ってファイルの修正を行います。
    https://github.com/disco-v8/DirectEdit/#使い方usage

    textedit.conf
    <?php
    // ------------------------------------------------------------
    //
    // txtedit.conf
    //
    // ------------------------------------------------------------
    ?>
    <?php
        $MYDNSJP_URL       = 'https://www.mydns.jp/directedit.html';
        $MYDNSJP_MASTERID  = '<MyDNSのID>';
        $MYDNSJP_MASTERPWD = '<MyDNSのパスワード>';
        $MYDNSJP_DOMAIN = 'example.dev';
    ?>
    

    最後に、SSL証明書を発行します。

    run_certbot_wildcard.sh(<PROJECT_NAME>/script/run_certbot_wildcard.sh)
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    DOCKER_PROJECT_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    CERTBOT_VOLUME_PATH=${DOCKER_PROJECT_PATH}/volume/certbot
    SSL_CERTIFICATE_PATH=<任意のパス>
    
    cd ${SCRIPT_DIRNAME}
    
    docker container run --rm \
    		     -it \
    		     -v ${CERTBOT_VOLUME_PATH}/workspace:/usr/local/share/workspace \
    		     -v /etc/group:/etc/group:ro \
    		     -v /etc/passwd:/etc/passwd:ro \
    		     -u $(id -u ${USER}):$(id -g ${USER}) \
    		     certbot \
    		     bash -c '
    		      cd /usr/local/share/workspace
    		      bash ./script/run_certbot_wildcard.sh
    		      sudo cp -rf /etc/letsencrypt/archive/example.dev/fullchain1.pem \
    				  ./certificate/wildcard/fullchain.pem
    		      sudo cp -rf /etc/letsencrypt/archive/example.dev/privkey1.pem \
    				  ./certificate/wildcard/privkey.pem
    		     '
    sudo cp -rf ${CERTBOT_VOLUME_PATH}/workspace/certificate/wildcard ${SSL_CERTIFICATE_PATH}
    
    run_certbot_wildcard.sh(<PROJECT_NAME>/volume/certbot/workspace/script/run_certbot_wildcard.sh)
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    WORKSPACE_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    cd ${SCRIPT_DIRNAME}
    
    # Production
    sudo certbot certonly --noninteractive \
    		      --manual \
    		      --preferred-challenges dns \
    		      --manual-auth-hook ${WORKSPACE_PATH}/hook/wildcard/DirectEdit-master/txtregist.php \
    		      --manual-cleanup-hook ${WORKSPACE_PATH}/hook/wildcard/DirectEdit-master/txtdelete.php \
    		      --domain *.example.dev \
    		      --agree-tos -m <メールアドレス> \
    		      --manual-public-ip-logging-ok
    

    <PROJECT_NAME>/volume/certbot/workspace/certificate/wildcardにfullchain.pemとprivkey.pemが配置されていれば成功です。
    今回は別のプロジェクトでもSSL証明書を利用できるように、~/share/certificate/wildcardのようなディレクトリにSSL証明書をコピーしました。

    これで、全体構成の「8. SSL証明書発行」ができるようになります。
    Let's Encryptで発行したSSL証明書は3ヶ月で失効するので、失効する前に前述のrun_certbot_wildcard.shを実行するようにしています。

  4. Traefikを構築する
    Traefikでリバースプロキシを構築します。
    必須ではないですが、Traefikを使うことでSSL証明書を一元管理でき、ホストマシンのポートを余計に潰さずに済みます。
    Traefikのドキュメントや有志の記事を参考に構築します。
    https://doc.traefik.io/traefik/
    https://zenn.dev/pitekusu/books/traefik-pitekusu
    (こちらの記事が個人的に大変分かりやすかったです。)

    ディレクトリ構成は次の通りです。

    <PROJECT_NAME>
    ├── docker
    │   └── compose.yml
    ├── script
    │   ├── docker_compose_down.sh
    │   └── docker_compose_up.sh
    ├── symlink
    │   └── traefik
    │       └── etc
    │           └── traefik
    │               └── certificate
    │                   └── wildcard -> ~/share/certificate/wildcardなど   ★SSL証明書へのシンボリックリンク
    └── volume
        └── traefik
    	└── etc
    	    └── traefik   ★コンテナにマウントするディレクトリ
    		├── configuration
    		│   └── dynamic_configuration.yml   ★Traefikの動的設定ファイル
    		└── traefik.yml   ★Traefikの静的設定ファイル
    

    Traefikは静的/動的に設定を反映することができます。
    静的設定ファイルはコンテナの/etc/traefik/traefik.ymlに書きます。

    traefik.yml
    api:
      insecure: true
      dashboard: true   ★ダッシュボード機能を有効化
    providers:
      docker:
        exposedByDefault: false
        network: reverse-proxy
      file:
        directory: /etc/traefik/configuration   ★動的設定ファイルを配置するパス
    entryPoints:   ★エントリーポイント
      web:
        address: :80
        http:
          redirections:
    	entryPoint:
    	  to: websecure
    	  scheme: https
      websecure:
        address: :443
    

    動的設定ファイルは静的設定ファイルに書いたパスに配置(マウント)します。

    dynamic_configuration.yml
    tls:
      certificates:   ★SSL証明書のパス
        - certFile: /etc/traefik/certificate/wildcard/fullchain.pem
          keyFile: /etc/traefik/certificate/wildcard/privkey.pem
    

    compose.ymlは次の通りです。

    compose.yml
    services:
      traefik:
        image: traefik:latest
        container_name: rp-traefik
        restart: always
        networks:
          - reverse-proxy
        ports:
          - 60080:80   ★HTTP
          - 60443:443   ★HTTPS
          - 58080:8080   ★ダッシュボード
        volumes:
          - ../volume/traefik/etc/traefik:/etc/traefik
          - ../symlink/traefik/etc/traefik/certificate/wildcard/:/etc/traefik/certificate/wildcard/:ro
          - /var/run/docker.sock:/var/run/docker.sock:ro
        stdin_open: true
        tty: true
    
    networks:
      reverse-proxy:
        external: true
    

    シンボリックリンクをコンテナにマウントする場合は、"/"をつけて実体(リンク先)をマウントするようにしています。
    適宜Dockerネットワークの作成を行って、コンテナを起動します。

    docker_compose_up.sh
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    DOCKER_PROJECT_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    cd ${DOCKER_PROJECT_PATH}/docker
    
    DOCKER_PROJECT_NAME=reverse-proxy
    DOCKER_NETWORK_NAME=${DOCKER_PROJECT_NAME}
    
    docker network create ${DOCKER_NETWORK_NAME}
    docker compose -p ${DOCKER_PROJECT_NAME} up -d --force-recreate
    

    無事コンテナを起動できたら、http://<ホストマシンのIPアドレス>:58080にアクセスしてみてください。
    Traefikのダッシュボードが表示されるはずです。
    Traefikのダッシュボード
    Traefikのダッシュボード
    また、自宅ルーターの設定で、80番ポート→ホストマシンの60080番ポート、443番ポート→ホストマシンの60443番ポートにポートフォワードするよう設定してください。
    これで、全体構成の「4. ポートフォワード」および「5. リバースプロキシ」ができるようになります。

  5. Nextcloudを構築する
    大本命のNextcloudを構築します。
    Nextcloudは公式でcompose.ymlが用意されているので、そちらを参考にします。
    https://github.com/nextcloud/all-in-one/blob/main/docker-compose.yml

    ディレクトリ構成は次の通りです。

    <PROJECT_NAME>
    ├── docker
    │   ├── compose.env
    │   └── compose.yml
    ├── script
    │   ├── docker_compose_down.sh
    │   └── docker_compose_up.sh
    └── volume
        ├── mariadb
        │   └── var
        │       └── lib
        │           └── mysql   ★コンテナにマウントするディレクトリ
        └── nextcloud
    	└── var
    	    └── www
    		└── html   ★コンテナにマウントするディレクトリ
    

    compose.ymlは次の通りです。

    compose.yml
    services:
      nextcloud:
        image: nextcloud:latest
        container_name: os-nextcloud
        depends_on:
          - mariadb
        restart: always
        networks:
          - online-storage
          - reverse-proxy
        labels:
          - traefik.enable=true
          # TCP
          - traefik.tcp.routers.nextcloud.rule=HostSNI(`www.example.dev`)
          - traefik.tcp.routers.nextcloud.entrypoints=websecure
          - traefik.tcp.routers.nextcloud.tls=true
          - traefik.tcp.routers.nextcloud.service=nextcloud
          - traefik.tcp.services.nextcloud.loadbalancer.server.port=80
          - traefik.tcp.services.nextcloud.loadbalancer.proxyprotocol.version=2
          # HTTP
          - traefik.http.routers.nextcloud.rule=Host(`www.example.dev`)
          - traefik.http.routers.nextcloud.entrypoints=websecure
          - traefik.http.routers.nextcloud.tls=true
          - traefik.http.routers.nextcloud.service=nextcloud
          - traefik.http.services.nextcloud.loadbalancer.server.port=80
        volumes:
          - ../volume/nextcloud/var/www/html:/var/www/html
          - /etc/group:/etc/group:ro
          - /etc/passwd:/etc/passwd:ro
        environment:
          - MYSQL_USER=<MariaDBのID>
          - MYSQL_PASSWORD=<MariaDBのパスワード>
          - MYSQL_DATABASE=<MariaDBのデータベース>
          - MYSQL_HOST=mariadb
        stdin_open: true
        tty: true
        user: ${USER_ID}:${GROUP_ID}
    
      mariadb:
        image: mariadb:latest
        container_name: os-mariadb
        restart: always
        command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
        networks:
          - online-storage
        volumes:
          - ../volume/mariadb/var/lib/mysql:/var/lib/mysql
          - /etc/group:/etc/group:ro
          - /etc/passwd:/etc/passwd:ro
        environment:
          - MYSQL_ROOT_PASSWORD=<MariaDBのルートパスワード>
          - MYSQL_USER=<MariaDBのID>
          - MYSQL_PASSWORD=<MariaDBのパスワード>
          - MYSQL_DATABASE=<MariaDBのデータベース>
        stdin_open: true
        tty: true
        user: ${USER_ID}:${GROUP_ID}
    
    networks:
      online-storage:
        external: true
      reverse-proxy:
        external: true
    

    リバースプロキシとしてTraefikを利用するので、labelsを沢山書いていますが、リバースプロキシを利用しない場合は、portsを書いて直接ホストマシンにポートフォワードしてください。
    また、コンテナにマウントするディレクトリ/ファイルの所有者がrootになるのが嫌だったので、ホストマシンの/etc/groupと/etc/passwdをリードオンリーでマウントし、userでユーザーID/グループIDを指定しました。

    compose.env
    USER_ID=1000
    GROUP_ID=1000
    

    適宜Dockerネットワークの作成を行って、コンテナを起動します。

    docker_compose_up.sh
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    DOCKER_PROJECT_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    cd ${DOCKER_PROJECT_PATH}/docker
    
    DOCKER_PROJECT_NAME=online-storage
    DOCKER_NETWORK_NAME=${DOCKER_PROJECT_NAME}
    
    docker network create ${DOCKER_NETWORK_NAME}
    docker compose --env-file ${DOCKER_PROJECT_PATH}/docker/compose.env -p ${DOCKER_PROJECT_NAME} up -d --force-recreate
    

    無事コンテナを起動できたら、https://www.example.dev にアクセスして、Nextcloudの初期設定を行ってください。
    (NextcloudのID/パスワードを入力する程度だったように記憶しています。)
    初期設定が完了すると、Nextcloudのダッシュボードが表示されるはずです。
    Nextcloudのダッシュボード
    Nextcloudのダッシュボード
    これで、全体構成の「6. 画像データ同期」ができるようになります。

  6. クライアントアプリを設定する
    スマートフォンで利用するクライアントアプリを設定します。
    クライアントアプリはNextcloud公式アプリでも良いですし、サードパーティのアプリがあればそちらでも構いません。
    今回はPhotoSync(有料プラグインあり)を利用しました。
    WebDAVを利用してNextcloudに画像をアップロードします。
    WebDAVのURLはhttps://www.example.dev/remote.php/dav/files/<NextcloudのID>です。
    PhotoSyncでは自動転送設定をすることが可能です。
    PhotoSyncの自動転送設定
    PhotoSyncの自動転送設定
    これで、全体構成の「3. 画像データ同期」ができるようになります。

  7. PhotoPrismを構築する
    おまけ程度ですが、PhotoPrismを構築します。
    Traefikと同様必須ではありません。
    公式でcompose.ymlが用意されているので、そちらを参考に構築します。
    https://github.com/photoprism/photoprism/blob/develop/docker-compose.latest.yml

    ディレクトリ構成は次の通りです。

    <PROJECT_NAME>
    ├── docker
    │   ├── compose.env
    │   └── compose.yml
    ├── script
    │   ├── docker_compose_down.sh
    │   ├── docker_compose_up.sh
    │   └── run_groundwork.sh
    ├── symlink
    │   └── photoprism
    │       └── photoprism
    │           └── originals
    │               └── backup -> <Nextcloudの画像データ>   ★Nextcloudの画像データへのシンボリックリンク
    └── volume
        ├── mariadb
        │   └── var
        │       └── lib
        │           └── mysql   ★コンテナにマウントするディレクトリ
        └── photoprism
    	└── photoprism
    	    ├── originals   ★コンテナにマウントするディレクトリ
    	    └── storage   ★コンテナにマウントするディレクトリ
    

    compose.ymlは次の通りです。

    compose.yml
    # Example Docker Compose config file for PhotoPrism (Linux / AMD64)
    #
    # Note:
    # - Hardware transcoding is only available for sponsors due to the high maintenance and support effort.
    # - Running PhotoPrism on a server with less than 4 GB of swap space or setting a memory/swap limit can cause unexpected
    #   restarts ("crashes"), for example, when the indexer temporarily needs more memory to process large files.
    # - If you install PhotoPrism on a public server outside your home network, please always run it behind a secure
    #   HTTPS reverse proxy such as Traefik or Caddy. Your files and passwords will otherwise be transmitted
    #   in clear text and can be intercepted by anyone, including your provider, hackers, and governments:
    #   https://docs.photoprism.app/getting-started/proxies/traefik/
    #
    # Setup Guides:
    # - https://docs.photoprism.app/getting-started/docker-compose/
    # - https://docs.photoprism.app/getting-started/raspberry-pi/
    #
    # Troubleshooting Checklists:
    # - https://docs.photoprism.app/getting-started/troubleshooting/
    # - https://docs.photoprism.app/getting-started/troubleshooting/docker/
    # - https://docs.photoprism.app/getting-started/troubleshooting/mariadb/
    #
    # CLI Commands:
    # - https://docs.photoprism.app/getting-started/docker-compose/#command-line-interface
    #
    # All commands may have to be prefixed with "sudo" when not running as root.
    # This will point the home directory shortcut ~ to /root in volume mounts.
    
    services:
      photoprism:
        ## Use photoprism/photoprism:preview for testing preview builds:
        image: photoprism/photoprism:latest
        container_name: ps-photoprism
        depends_on:
          - mariadb
        ## Don't enable automatic restarts until PhotoPrism has been properly configured and tested!
        ## If the service gets stuck in a restart loop, this points to a memory, filesystem, network, or database issue:
        ## https://docs.photoprism.app/getting-started/troubleshooting/#fatal-server-errors
        restart: always
        networks:
          - photo-storage
          - reverse-proxy
        labels:
          - traefik.enable=true
          # TCP
          - traefik.tcp.routers.photoprism.rule=HostSNI(`www2.example.dev`)
          - traefik.tcp.routers.photoprism.entrypoints=websecure
          - traefik.tcp.routers.photoprism.tls=true
          - traefik.tcp.routers.photoprism.service=photoprism
          - traefik.tcp.services.photoprism.loadbalancer.server.port=2342
          - traefik.tcp.services.photoprism.loadbalancer.proxyprotocol.version=2
          # HTTP
          - traefik.http.routers.photoprism.rule=Host(`www2.example.dev`)
          - traefik.http.routers.photoprism.entrypoints=websecure
          - traefik.http.routers.photoprism.tls=true
          - traefik.http.routers.photoprism.service=photoprism
          - traefik.http.services.photoprism.loadbalancer.server.port=2342
        security_opt:
          - seccomp:unconfined
          - apparmor:unconfined
        # ports:
        #   - 2342:2342 # HTTP port (host:container)
        environment:
          PHOTOPRISM_ADMIN_USER: <PhotoPrismのID> # superadmin username
          PHOTOPRISM_ADMIN_PASSWORD: <PhotoPrismのパスワード> # initial superadmin password (minimum 8 characters)
          PHOTOPRISM_AUTH_MODE: password # authentication mode (public, password)
          PHOTOPRISM_SITE_URL: https://www2.example.dev # server URL in the format "http(s)://domain.name(:port)/(path)"
          PHOTOPRISM_ORIGINALS_LIMIT: 5000 # file size limit for originals in MB (increase for high-res video)
          PHOTOPRISM_HTTP_COMPRESSION: gzip # improves transfer speed and bandwidth utilization (none or gzip)
          PHOTOPRISM_LOG_LEVEL: info # log level: trace, debug, info, warning, error, fatal, or panic
          PHOTOPRISM_READONLY: false # do not modify originals directory (reduced functionality)
          PHOTOPRISM_EXPERIMENTAL: false # enables experimental features
          PHOTOPRISM_DISABLE_CHOWN: false # disables updating storage permissions via chmod and chown on startup
          PHOTOPRISM_DISABLE_WEBDAV: false # disables built-in WebDAV server
          PHOTOPRISM_DISABLE_SETTINGS: false # disables settings UI and API
          PHOTOPRISM_DISABLE_TENSORFLOW: false # disables all features depending on TensorFlow
          PHOTOPRISM_DISABLE_FACES: false # disables face detection and recognition (requires TensorFlow)
          PHOTOPRISM_DISABLE_CLASSIFICATION: false # disables image classification (requires TensorFlow)
          PHOTOPRISM_DISABLE_RAW: false # disables indexing and conversion of RAW files
          PHOTOPRISM_RAW_PRESETS: false # enables applying user presets when converting RAW files (reduces performance)
          PHOTOPRISM_JPEG_QUALITY: 85                    # a higher value increases the quality and file size of JPEG images and thumbnails (25-100)
          PHOTOPRISM_DETECT_NSFW: false # automatically flags photos as private that MAY be offensive (requires TensorFlow)
          PHOTOPRISM_UPLOAD_NSFW: true # allows uploads that MAY be offensive (no effect without TensorFlow)
          # PHOTOPRISM_DATABASE_DRIVER: sqlite # SQLite is an embedded database that doesn't require a server
          PHOTOPRISM_DATABASE_DRIVER: mysql # use MariaDB 10.5+ or MySQL 8+ instead of SQLite for improved performance
          PHOTOPRISM_DATABASE_SERVER: mariadb:3306 # MariaDB or MySQL database server (hostname:port)
          PHOTOPRISM_DATABASE_NAME: <MariaDBのデータベース> # MariaDB or MySQL database schema name
          PHOTOPRISM_DATABASE_USER: <MariaDBのID> # MariaDB or MySQL database user name
          PHOTOPRISM_DATABASE_PASSWORD: <MariaDBのパスワード> # MariaDB or MySQL database user password
          PHOTOPRISM_SITE_CAPTION: AI-Powered Photos App
          PHOTOPRISM_SITE_DESCRIPTION: True Alternative for Google Photos # meta site description
          PHOTOPRISM_SITE_AUTHOR: <任意の名前> # meta site author
          ## Run/install on first startup (options: update https gpu tensorflow davfs clitools clean):
          # PHOTOPRISM_INIT: https gpu tensorflow
          ## Hardware Video Transcoding:
          # PHOTOPRISM_FFMPEG_ENCODER: software # FFmpeg encoder ("software", "intel", "nvidia", "apple", "raspberry")
          # PHOTOPRISM_FFMPEG_BITRATE: 32 # FFmpeg encoding bitrate limit in Mbit/s (default: 50)
          ## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):
          PHOTOPRISM_UID: ${USER_ID}
          PHOTOPRISM_GID: ${GROUP_ID}
          # PHOTOPRISM_UMASK: 0000
        ## Start as non-root user before initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):
        stdin_open: true
        tty: true
        user: ${USER_ID}:${GROUP_ID}
        ## Share hardware devices with FFmpeg and TensorFlow (optional):
        # devices:
        #  - /dev/dri:/dev/dri # Intel QSV
        #  - /dev/nvidia0:/dev/nvidia0 # Nvidia CUDA
        #  - /dev/nvidiactl:/dev/nvidiactl
        #  - /dev/nvidia-modeset:/dev/nvidia-modeset
        #  - /dev/nvidia-nvswitchctl:/dev/nvidia-nvswitchctl
        #  - /dev/nvidia-uvm:/dev/nvidia-uvm
        #  - /dev/nvidia-uvm-tools:/dev/nvidia-uvm-tools
        #  - /dev/video11:/dev/video11 # Video4Linux Video Encode Device (h264_v4l2m2m)
        working_dir: /photoprism # do not change or remove
        ## Storage Folders: "~" is a shortcut for your home directory, "." for the current directory
        volumes:
          # /host/folder:/photoprism/folder # Example
          - ../volume/photoprism/photoprism/originals:/photoprism/originals # Original media files (DO NOT REMOVE)
          # - /example/family:/photoprism/originals/family # *Additional* media folders can be mounted like this
          - ../symlink/photoprism/photoprism/originals/backup/:/photoprism/originals/backup/:ro # *Additional* media folders can be mounted like this
          # - ~/Import:/photoprism/import # *Optional* base folder from which files can be imported to originals
          - ../volume/photoprism/photoprism/storage:/photoprism/storage # *Writable* storage folder for cache, database, and sidecar files (DO NOT REMOVE)
    
      ## Database Server (recommended)
      ## see https://docs.photoprism.app/getting-started/faq/#should-i-use-sqlite-mariadb-or-mysql
      mariadb:
        ## If MariaDB gets stuck in a restart loop, this points to a memory or filesystem issue:
        ## https://docs.photoprism.app/getting-started/troubleshooting/#fatal-server-errors
        restart: always
        image: mariadb:latest
        container_name: ps-mariadb
        networks:
          - photo-storage
        security_opt: # see https://github.com/MariaDB/mariadb-docker/issues/434#issuecomment-1136151239
          - seccomp:unconfined
          - apparmor:unconfined
        command: mysqld --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --max-connections=512 --innodb-rollback-on-timeout=OFF --innodb-lock-wait-timeout=120
        ## Never store database files on an unreliable device such as a USB flash drive, an SD card, or a shared network folder:
        volumes:
          - ../volume/mariadb/var/lib/mysql:/var/lib/mysql # DO NOT REMOVE
        environment:
          MARIADB_AUTO_UPGRADE: 1
          MARIADB_INITDB_SKIP_TZINFO: 1
          MARIADB_DATABASE: <MariaDBのデータベース>
          MARIADB_USER: <MariaDBのID>
          MARIADB_PASSWORD: <MariaDBのパスワード>
          MARIADB_ROOT_PASSWORD: <MariaDBのルートパスワード>
        stdin_open: true
        tty: true
        user: ${USER_ID}:${GROUP_ID}
    
    networks:
      photo-storage:
        external: true
      reverse-proxy:
        external: true
    

    ちょっと長いですが、やっていることはNextcloudと大きく変わりません。
    適宜Dockerネットワークの作成を行って、コンテナを起動します。

    docker_compose_up.sh
    #! /usr/bin/env bash
    
    SCRIPT_DIRNAME=$(cd $(dirname ${0}) && pwd)
    DOCKER_PROJECT_PATH=$(cd $(dirname ${0}) && cd ../ && pwd)
    
    cd ${DOCKER_PROJECT_PATH}/docker
    
    DOCKER_PROJECT_NAME=photo-storage
    DOCKER_NETWORK_NAME=${DOCKER_PROJECT_NAME}
    
    docker network create ${DOCKER_NETWORK_NAME}
    docker compose --env-file ${DOCKER_PROJECT_PATH}/docker/compose.env -p ${DOCKER_PROJECT_NAME} up -d --force-recreate
    

    無事コンテナを起動できたら、https://www2.example.dev にアクセスしてください。
    PhotoPrismのログイン画面が表示されるので、ログインします。
    起動したばかりだと画像データのインデックスが作成されていないので、GUIから作成します。
    PhotoPrismでインデックスを作成
    PhotoPrismでインデックスを作成
    画像データが多いとそれだけ時間がかかります、気長に待ちましょう。
    インデックスが作成されると、場所やラベルから写真を検索できるようになります。
    PhotoPrismにおける場所検索
    PhotoPrismにおける場所検索
    PhotoPrismにおけるラベル検索
    PhotoPrismにおけるラベル検索
    さすがにGoogleフォトほど使いやすくはありませんね…🫠

🌕終わりに

今回はNextcloud/PhotoPrismでフォトストレージ(オンプレミス)を構築しました。
「外出先で写真撮影して、ホテルのWi-Fiに接続したら、画像データがフォトストレージに同期される」という目標も達成できましたし、システム構成やネットワーク構成を考える時間、Dockerで試行錯誤する時間も今振り返ればとても楽しいものでした。
AmazonやGoogleのフォトストレージを利用する方が圧倒的に楽ですが、誰かの役に立てれば嬉しいです。
皆様のますますのご活躍と、健やかなDockerライフを心よりお祈り申し上げます。
新宿で食べた煮干し中華そば
新宿で食べた煮干し中華そば

Discussion