
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.
My home network is built around three unRAID servers and a stack of Synology NAS units, segmented into VLANs for different purposes.
| 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.
https://lychee.yourdomain.comLychee 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.
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.
/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
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.
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
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 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.
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.
This is where Lychee really shines for blogging. Every album and sub-album has a built-in embed code generator.
Getting the embed code:
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>
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.
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.