Person typing on laptop with dual monitors and indoor plants on desk



By Kevin Cossaboon · April 2026
Credit to Claude 1.3109.0 (35cbf6) 2026-04-16T20:32:01.000Z


I’ve been shooting photos for years and always struggled with a clean way to share them. Cloud services like Google Photos or Flickr work, but I wanted something I owned — running on my own hardware, behind my own domain, with my watermark automatically applied before anything goes public. This post covers how I built exactly that using Lychee, running on my home lab, with a fully automated pipeline from camera to website.


The Infrastructure

My home network is built around three unRAID servers and a stack of Synology NAS units, segmented into VLANs for different purposes.

Network Layout

Network Subnet Purpose
Dont_Panic (Core) 10.10.0.0/16 Main devices, workstations, servers
DeepThought (Backend) 10.42.42.0/24 10GbE backend — servers talk to each other here
MostlyHarmless (IoT) 10.30.30.0/24 Smart home devices

The backend network (VLAN42) is where the heavy lifting happens — direct server-to-server traffic at 10GbE without touching the main LAN.

Key Servers

  • Space Dock — unRAID test/dev server where Lychee runs
  • DockOfTheBay — dedicated DNS, load balancing, and authentication server running Nginx Proxy Manager and Authelia MFA
  • NAS2 — Synology NAS providing the photo storage

What I Needed

  • A photo gallery accessible at https://lychee.yourdomain.com
  • SSL termination and MFA handled centrally, not per-app
  • Photos stored on NAS, not inside Docker volumes
  • Automatic watermarking before anything goes public
  • Clean URLs embeddable in my WordPress blog

The Solution: Lychee

Lychee is a self-hosted photo management application built on Laravel. It’s beautiful, fast, and has a feature I particularly love: embeddable photo streams and albums — perfect for dropping a gallery right into a WordPress post.

Container Architecture

The Lychee stack runs as three Docker containers managed by the Compose Manager addon on unRAID:

Container Role
lychee Web application, port 8000 (dual-homed: backend + core network)
lychee_worker Background jobs (thumbnails, EXIF)
lychee_db MariaDB 10 database

The lychee container is dual-homed: it sits on both the backend network (to talk to the database) and the core network (so the Nginx load balancer can reach it). This required learning an important unRAID networking quirk — more on that below.

Storage Layout on NAS

/volume1/photoshare/
  lychee/       ← Lychee's upload directory (mounts into container)
  incoming/     ← Drop zone for new shoots

A persistent staging directory lives on the unRAID server itself:

/mnt/user/appdata/lychee/stage/   ← watermarked photos, ready to import

The Workflow

Here’s the end-to-end flow once everything is set up:

Camera SD Card
     ↓
  Copy to NAS
  /volume1/photoshare/incoming/Disney April 2026/Day 1/
                                                  Day 2/
                                                  Day 3/
     ↓
  Pipeline script runs (every 15 minutes, User Scripts on unRAID)
     ↓
  For each photo:
    1. Detect brightness of bottom-right corner
    2. Apply black watermark (light photo) or white watermark (dark photo)
    3. Scale watermark to exactly 20% of photo pixel width
    4. Save to /appdata/lychee/stage/Disney April 2026/Day 1/
     ↓
  docker exec lychee php artisan lychee:sync
    → Creates album "Disney April 2026" with sub-albums Day 1, Day 2, Day 3
    → Skips duplicates on re-runs
     ↓
  Photos appear in Lychee gallery
     ↓
  Copy embed code from Lychee → paste into WordPress post

The directory structure directly maps to the album hierarchy in Lychee. A folder called Disney April 2026/Day 1/ becomes a parent album with a sub-album — no manual album creation needed.


Technical Details

Networking: The Hard Parts

Getting the Docker networking right took the most iteration.

Problem 1: macvlan alias

unRAID uses macvlan Docker networks. When referencing an external macvlan network in Docker Compose, the network must be declared with a name alias or it silently fails to route:

# WRONG — breaks external macvlan routing on unRAID
networks:
  br2:
    external: true

# CORRECT — use a local alias with name:
networks:
  proxynet:
    external: true
    name: br2

I discovered this by looking at how another working app (Immich) handled it on the same server.

Problem 2: Host cannot reach its own macvlan containers

The unRAID host itself cannot communicate with containers on its own macvlan networks. Any automation that needs to talk to Lychee must use docker exec lychee ... instead of making HTTP calls to the container’s IP.

Problem 3: SSL and trusted proxies

Since Nginx Proxy Manager handles SSL termination, Lychee must not be configured to force HTTPS — it will cause redirect loops. Set APP_FORCE_HTTPS=false and trust the proxy ranges:

TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12

The docker-compose.yml

services:

  lychee_db:
    image: mariadb:10
    container_name: lychee_db
    restart: unless-stopped
    env_file: .env
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    volumes:
      - /mnt/user/appdata/lychee/db:/var/lib/mysql
    networks:
      br0:
        ipv4_address: 10.42.42.12
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s

  lychee:
    image: ghcr.io/lycheeorg/lychee:latest
    container_name: lychee
    restart: unless-stopped
    env_file: .env
    environment:
      LYCHEE_MODE: web
    volumes:
      - /mnt/remotes/NAS_photoshare/lychee:/app/public/uploads
      - /mnt/user/appdata/lychee/sym:/app/public/sym
      - /mnt/user/appdata/lychee/logs:/app/storage/logs
      - /mnt/user/appdata/lychee/cache:/app/bootstrap/cache
      - /mnt/remotes/NAS_photoshare/incoming:/import:ro
      - /mnt/user/appdata/lychee/stage:/import-stage:ro
    networks:
      br0:
        ipv4_address: 10.42.42.13
      proxynet:
        ipv4_address: 10.10.20.77
    depends_on:
      lychee_db:
        condition: service_healthy

  lychee_worker:
    image: ghcr.io/lycheeorg/lychee:latest
    container_name: lychee_worker
    restart: unless-stopped
    env_file: .env
    environment:
      LYCHEE_MODE: worker
    volumes:
      - /mnt/remotes/NAS_photoshare/lychee:/app/public/uploads
      - /mnt/user/appdata/lychee/sym:/app/public/sym
      - /mnt/user/appdata/lychee/logs:/app/storage/logs
      - /mnt/user/appdata/lychee/cache:/app/bootstrap/cache
    networks:
      br0:
        ipv4_address: 10.42.42.14
    depends_on:
      lychee_db:
        condition: service_healthy

networks:
  br0:
    external: true
  proxynet:
    external: true
    name: br2

The Automated Watermark Pipeline

The pipeline script (process-photos.sh) runs every 15 minutes via the unRAID User Scripts plugin. It handles everything from drop-zone to Lychee automatically.

Smart watermark selection

I have two versions of my watermark — black signature for light photos, white signature for dark photos. The script detects which to use by sampling the brightness of the bottom-right corner of each photo (where the watermark will land):

brightness=$(docker run --rm --entrypoint magick \
    -v "${src_dir}:/img:ro" \
    dpokidov/imagemagick:latest \
    "/img/$fname" \
    -gravity SouthEast -crop 30%x15%+0+0 +repage \
    -colorspace gray \
    -format "%[fx:mean*100]" info:)

# >50% brightness = light background → use dark watermark
if awk "BEGIN{exit !($brightness > 50)}"; then
    wm_file="dark.png"
else
    wm_file="light.png"
fi

Proportional watermark sizing

A critical lesson: ImageMagick’s -resize '22%x' scales the watermark to 22% of the watermark’s own size, not the photo. My watermark PNG is 7500px wide — 22% of that is 1650px, which overwhelms a lower-resolution photo. The fix is to measure the photo first and calculate a pixel target:

photo_width=$(docker run --rm --entrypoint magick \
    -v "${src_dir}:/img:ro" \
    dpokidov/imagemagick:latest \
    identify -format "%w" "/img/$fname")

wm_width=$(( photo_width * 20 / 100 ))   # always 20% of THIS photo's width

Persistent staging (not /tmp)

Watermarked copies are stored in /mnt/user/appdata/lychee/stage/ — not /tmp, which is wiped on every unRAID reboot. The script checks if a staged file already exists and is newer than the source before re-processing, so re-runs are fast.

ImageMagick v7 gotcha

The dpokidov/imagemagick:latest container runs ImageMagick v7, but its entrypoint is the legacy convert command. Passing magick as a subcommand causes it to treat the word “magick” as an input filename. The fix is --entrypoint magick:

docker run --rm --entrypoint magick \
    -v "${src_dir}:/img:ro" \
    dpokidov/imagemagick:latest \
    "/img/$fname" ...

Lychee v5 import command

The artisan command changed between versions. In Lychee v5 the correct command is lychee:sync, not lychee:import:

docker exec lychee php artisan lychee:sync \
    "/import-stage/$shoot_name" \
    --skip_duplicates=1 \
    --delete_imported=0

This syncs the entire directory tree, creating nested albums automatically from the folder structure and skipping photos already in Lychee on re-runs.

Nginx Proxy Manager Configuration

On DockOfTheBay, a proxy host entry forwards lychee.yourdomain.com to http://<lychee-container-ip>:8000. The existing Authelia authentication block is applied in the Advanced tab, giving Lychee the same MFA protection as all other self-hosted apps on the network:

client_max_body_size 500M;
proxy_read_timeout 300;

No separate nginx config file is needed — NPM handles it through its UI.


Embedding Albums in WordPress

This is where Lychee really shines for blogging. Every album and sub-album has a built-in embed code generator.

Getting the embed code:

  1. Open an album in Lychee
  2. Click the share/embed icon
  3. Copy the generated HTML

One gotcha: Lychee sometimes generates data-layout="null" in the embed code. Change it to data-layout="masonry" (or filmstrip / grid).

For parent albums with sub-albums (e.g. Disney April 2026 → Day 1 through Day 6): the embed widget only renders photos directly in an album, not sub-albums. Embed each day’s sub-album individually with a heading above each:

<!-- WordPress Custom HTML block -->

<h2>Day 1 — Magic Kingdom</h2>

<!-- Lychee Photo Album Embed v1.0.0 -->
<link rel="stylesheet" href="https://lychee.yourdomain.com/embed/lychee-embed.css?v=1.0.0">
<script src="https://lychee.yourdomain.com/embed/lychee-embed.js?v=1.0.0"></script>

<div
    data-lychee-embed
    data-api-url="https://lychee.yourdomain.com"
    data-mode="album"
    data-album-id="YOUR_DAY1_ALBUM_ID"
    data-layout="masonry"
    data-spacing="8"
    data-target-row-height="200"
    data-max-photos="50"
    data-sort-order="asc"
></div>

<h2>Day 2 — EPCOT</h2>

<div
    data-lychee-embed
    data-api-url="https://lychee.yourdomain.com"
    data-mode="album"
    data-album-id="YOUR_DAY2_ALBUM_ID"
    data-layout="masonry"
    data-spacing="8"
    data-target-row-height="200"
    data-max-photos="50"
    data-sort-order="asc"
></div>

You can also embed a stream of your most recent public photos across all albums — useful for a photography homepage:

<div
    data-lychee-embed
    data-api-url="https://lychee.yourdomain.com"
    data-mode="stream"
    data-layout="filmstrip"
    data-max-photos="18"
    data-sort-order="desc"
></div>

What I’d Do Differently

Authelia on the embed — because the Lychee albums are set to public, the embed works without authentication. If I wanted private albums on WordPress, I’d need to think through that flow more carefully.

RAW file handling — the pipeline currently only handles JPEGs and PNGs. My Leica shoots RAW + JPEG pairs; I’ll add logic to skip RAW files (.dng, .raf) rather than failing on them.

Watermark position — currently fixed to the bottom-right. For portrait photos where the subject is in the bottom-right corner, it would be better to detect faces/subjects and move the watermark dynamically. A future project.


Summary

The whole stack — photo gallery, automated watermarking, MFA authentication, and WordPress integration — runs entirely on hardware I own, behind my own domain, with zero monthly fees beyond electricity and internet. Drop a folder of photos on the NAS, wait 15 minutes, and a fully watermarked, publicly embeddable album appears in Lychee ready to paste into a blog post.

For anyone running a home lab on unRAID with Synology storage, this setup transfers almost directly. The trickiest parts are the macvlan networking quirks specific to unRAID — hopefully this post saves you the hours I spent debugging those.


All photos on this site are copyright Kevin R. Cossaboon Photography. Watermarks are applied automatically — please don’t crop them out.

About the Author

Kevin Cossaboon

A networking profesional located in Northren Virginia, USA. My hobbies are Technology and Photography. Love playing with the latest technology, and will try to post reviews of them. Also love my life long journey of learning to capture light, to trigger emotions, through photography.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.