A self-hosted photo display system for e-ink photo frames. It pairs a Node/Express backend + web dashboard with ESP32-S3 e-ink firmware, so you can push photos to battery-powered displays over Wi-Fi, update them over the air, and manage everything from one place. It also exposes a simple image API, so it doubles as an image source for external displays like TRMNL.
One codebase builds firmware for multiple boards (Waveshare PhotoPainter and Seeed reTerminal E1001), and the dashboard handles onboarding, pairing, OTA updates and per-screen settings.
Dashboard & photos
- Upload and manage photos through a web UI, organised into screens (named collections)
- Per-screen look: fit mode, brightness/saturation, sleep schedule, refresh interval
- Usage metrics, activity log, and per-screen device logs
- Users, roles, 2FA, and API-key management
E-ink devices
- Guided setup wizard: build + flash firmware right from the browser
- QR-code pairing links a device to a screen in seconds
- Over-the-air updates (opt-in per screen) with a firmware version history you can roll back to
- Multi-board: one firmware codebase, model-aware builds and OTA
- Add your own board by pointing at a GitHub firmware repo (admin-only)
API & integrations
- Simple REST API to serve a rotating slideshow image (works great with TRMNL)
- API keys with per-key refresh intervals and configurable output size
Security
- Firmware downloads are API-key gated
- Device Wi-Fi passwords are encrypted at rest (AES-256-GCM);
config.his wiped after each build - Optional HTTPS with auto-generated self-signed certificates
| Board | Panel | Notes |
|---|---|---|
| Waveshare 7.3″ PhotoPainter | E6 Spectra-6 (6-colour), 800×480 | AXP2101 PMIC, battery |
| Seeed reTerminal E1001 | UC8179 7.5″ monochrome, 800×480 | Floyd–Steinberg dithered photos |
You can also register a custom device in the dashboard (a GitHub firmware repo the server clones and builds).
The image is published to GitHub Container Registry on every release.
# docker-compose.yml already points at ghcr.io/mischa323/photodock:latest
docker compose up -d- HTTP:
http://localhost:8080 - HTTPS:
https://localhost:8081(self-signed cert auto-generated atdata/ssl/)
Data (database, uploads, SSL, encryption key) persists in the ./data volume.
PhotoDock runs well as a Portainer stack using the published image.
-
Registry (only if the image is private): Registries → Add registry → Custom, set the URL to
ghcr.ioand authenticate with your GitHub username + a Personal Access Token that hasread:packages. Skip this if thephotodockpackage is public. -
Stacks → Add stack, name it
photodock, and paste this into the web editor:services: photodock: image: ghcr.io/mischa323/photodock:latest ports: - "8080:8080" # change the left number if the host port is taken - "8081:8081" volumes: - photodock-data:/data # database, uploads, certs, encryption key - photodock-pio:/root/.platformio # firmware-build cache (optional) # environment: # PHOTODOCK_SECRET_KEY: "<64 hex chars>" # optional: pin the at-rest key restart: unless-stopped volumes: photodock-data: photodock-pio:
-
Deploy the stack, then open
http://<host>:8080and create your admin account.
The image already sets sensible defaults (PORT, HTTPS_PORT, DATA_FILE,
UPLOADS_DIR, SSL paths), so no environment variables are required.
Updating: open the stack → Pull and redeploy to move to the newest
photodock:latest. The photodock-data volume keeps your data, certificates and the
at-rest encryption key across updates — don't delete it.
git clone https://github.com/Mischa323/PhotoDock.git
cd PhotoDock
npm install
npm start- Open the web interface
- Create your admin account (email is required)
- Log in to reach the dashboard
From the dashboard, open Set up a device and follow the wizard:
- Create a screen — the photos the device will show. An API key is created and applied automatically (no manual entry needed).
- Choose a setup mode:
- Automatic — enter your Wi-Fi + server details; the firmware is built with them baked in and flashed from the browser. The password is encrypted at rest on the server and never left in plaintext.
- Manual — flash credential-free firmware, then configure the device through its
own
PhotoDock-XXXXWi-Fi setup portal on first boot.
- Flash — over USB, straight from the browser (Chrome/Edge, Web Serial).
- Pair — the device shows a QR code; scan it (while logged in) and pick a screen.
Open a screen and choose Update now, or enable auto-update. The device pulls only its own model's firmware on its next wake. Every published build is archived, so you can revert to a previous version from Settings → Firmware source → Version history.
Devices connect to the server over HTTPS when their server port is 443 — ideal when the server sits behind a TLS‑terminating reverse proxy (Nginx, Caddy, Cloudflare Tunnel, etc.).
When setting up a device (wizard or the on‑device portal), set:
- Server host: your domain (e.g.
photodock.example.com) - Server port:
443
The firmware then talks to https://your-domain/… (the :443 is omitted so virtual‑host routing works). Certificate validation is skipped on the device, so Let's Encrypt, self‑signed and proxy certificates all work — traffic is still encrypted. OTA updates flow over the same HTTPS connection.
Make sure your proxy forwards /api/… and /firmware/… to the backend and allows firmware downloads of ~1 MB. Any other port keeps using plain HTTP.
Firmware lives in esp32/ and builds with PlatformIO.
cd esp32
pio run -e esp32s3-photopainter # Waveshare PhotoPainter (E6)
pio run -e reterminal-e1001 # Seeed reTerminal E1001
pio run -e reterminal-e1001 -t upload # build + flash over USB
esp32/src/config.hholds optional baked Wi-Fi/server credentials. Leave them empty to force the on-device setup portal. Builds with baked credentials are not published to the network-servedfirmware_build/folder.
All endpoints require an API key, passed as ?key=YOUR_API_KEY or an x-api-key header.
| Endpoint | Returns |
|---|---|
GET /api/slideshow/image |
The current image as a pre-rendered JPEG, sized to the key's configured dimensions (default 800×480 for e-ink) |
GET /api/slideshow/current |
The current image's metadata/URL |
GET /api/slideshow/all |
All images for the key's screen |
Each API key keeps its own slideshow timer; when its interval expires, the next image is
selected and /api/slideshow/image returns it.
curl -H "x-api-key: YOUR_API_KEY" https://your-server/api/slideshow/image --output frame.jpgPhotoDock works as an image source for the TRMNL Image Display plugin.
- Expose your server publicly — TRMNL can't reach
localhost/192.168.x.x. Use a tunnel (Cloudflare Tunnel), reverse proxy, or port-forward; valid HTTPS recommended. - Use the image URL:
https://your-domain.com/api/slideshow/image?key=YOUR_API_KEY - In TRMNL: Plugins → Image Display, paste the URL, save.
- Match the TRMNL refresh interval to your API key's interval (e.g. 10 min key ↔ 10–15 min refresh).
The image API currently sends
Cache-Control: no-cachewithoutETag/Last-Modified, so a caching proxy in front can help TRMNL detect changes reliably. The Screenshot plugin is an alternative if you prefer header auth (x-api-key).
Environment variables (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP port |
HTTPS_PORT |
8081 |
HTTPS port (active when a cert exists) |
DATA_FILE |
data.json |
Database file location |
UPLOADS_DIR |
uploads/ |
Uploaded images |
SSL_CERT / SSL_KEY |
— | Paths to TLS cert/key (else self-signed) |
PHOTODOCK_SECRET_KEY |
auto-generated | 32-byte key (hex/base64) for encrypting secrets at rest. If unset, a random key is written to secret.key next to DATA_FILE |
APP_VERSION |
package.json version |
Version shown in the dashboard |
Keep the
data/volume (Docker) orsecret.key(source) — it holds the encryption key that decrypts stored Wi-Fi passwords. SetPHOTODOCK_SECRET_KEYto control it explicitly.
backend/ Node/Express server (API, dashboard backend, firmware build/serve)
frontend/ Web dashboard (screens, devices, admin, setup wizard)
esp32/ PlatformIO firmware (multi-board: board.h + per-board envs)
firmware_build/ Built, network-served firmware per model (git-ignored)
.github/workflows/docker.yml Builds & pushes the ghcr.io image on push/tag
MIT License