A beautiful, web-based digital photo frame with user management, gallery controls, and HTTPS support. Runs on any Linux system — commonly deployed on a Raspberry Pi connected to a TV.
- Web Upload Interface - Upload photos from any device on your network
- Multi-User Support - Admin can create user accounts
- Gallery Management - Show/hide photos, bulk actions, delete
- Password Protection - Secure login with forced password change on first login
- Duplicate Detection - Perceptual hashing warns before uploading duplicate images
- Individual Image Scale - Adjust zoom per image or match heights across a group
- Image Cropping - Interactive crop overlay with drag-and-resize handles
- Customizable Mat Colors - 10 neutral presets (white to brown) plus 25 accent colors, per-image overrides
- Mat Finishes - Eggshell (default), Flat, Linen, Suede, or Silk texture overlays
- Border Effects - Inner bevel (uniform color, 0-16px) or drop shadow
- Smooth Slideshow - Configurable timing and transitions (default 60s)
- Thumbnail Generation - Auto-generates 400px thumbnails for fast gallery loading
- Drag & Drop Reordering - Arrange photos in your preferred order
- TV Power Schedule (HDMI-CEC) - Automatically turn your TV on/off on a schedule
- HTTPS - Self-signed, Let's Encrypt via Cloudflare, or Let's Encrypt via DuckDNS
- Kiosk Mode - Auto-starts on boot for dedicated displays
- Remote Access (Tailscale) - Securely manage your frame from anywhere
- CI/CD Deployment - Automated updates via GitHub Actions with maintenance window
git clone https://github.com/abwagner/pi-photo-frame.git
cd pi-photo-frame
./scripts/install.shThe install script handles everything: Docker installation, building and starting the app with HTTPS, optional Chromium kiosk mode, and a daily Chromium restart cron job to prevent memory leaks.
After setup, access at https://<your-ip>/upload. If your system runs Avahi/mDNS (default on Raspberry Pi OS and most desktop Linux distros), you can also use https://<your-hostname>.local/upload.
- Any Linux machine (Raspberry Pi, Ubuntu server, etc.) with a desktop environment if using kiosk mode
- Network connection
- HDMI cable to a TV/monitor (if using as a dedicated display)
ssh user@your-host
git clone https://github.com/abwagner/pi-photo-frame.git
cd pi-photo-frame
./scripts/install.shThe script will:
- Install Docker and enable it to start on boot
- Ask which HTTPS mode you want (self-signed, Cloudflare, or DuckDNS)
- Build and start the photo frame app with Caddy (HTTPS)
- Ask if you want Chromium kiosk mode for a connected display
- Ask if you want HDMI-CEC TV power control
- Ask if you want Tailscale for secure remote access
- Add a daily cron job (4:00 AM) to restart Chromium and prevent memory leaks
If you chose kiosk mode, reboot to start it:
sudo rebootFrom any device on your network (phone, laptop, etc.):
- Open
https://<your-ip>/upload(orhttps://<your-domain>/uploadif using Let's Encrypt) - Log in with
admin/password - You'll be prompted to set a new password before continuing
- Upload photos — they appear on the display automatically
These commands are run via SSH on the host machine:
| Task | Command |
|---|---|
| View logs | docker compose logs -f |
| Restart | docker compose restart |
| Stop | docker compose down |
| Update code | git pull && docker compose up -d --build |
| Exit kiosk temporarily | Press Ctrl+Alt+F1 to switch to terminal |
| Return to kiosk | Press Ctrl+Alt+F7 to switch back to desktop |
Once kiosk mode is set up, you never need to touch the machine again. Everything is managed through the web interface from your phone or laptop. It just needs power and an HDMI connection to your display.
Username: admin
Password: password
On first login, you'll be redirected to a password change page. You must set a new password before accessing any other features. This prevents the default credentials from being left active.
| Page | URL | Description |
|---|---|---|
| Login | /login |
Sign in |
| Upload | /upload |
Upload photos, adjust settings |
| Gallery | /gallery |
Manage photos (show/hide/delete) |
| Display | /display |
Fullscreen slideshow for TV |
| Backup | /backup |
Dropbox backup management (admin only) |
| Users | /admin/users |
User management (admin only) |
| Role | Permissions |
|---|---|
| Admin | Upload, manage gallery, manage users, change settings, manage backup |
| User | Upload, manage gallery, change own password |
The gallery page lets you:
- Show/Hide Photos - Toggle visibility without deleting
- Group Images - Click "Group" to select images that display together as a multi-image slide
- Bulk Actions - Select mode for show/hide/delete across multiple photos
- Filter View - Show all, visible only, or hidden only
- Upload Order - Gallery shows newest uploads first
- See Metadata - Upload date, uploader, file size
Hidden photos remain on disk but won't appear in the slideshow.
Settings are organized into two tabs in the sidebar:
| Setting | Description |
|---|---|
| Mat Color | Background color around photos (10 neutrals + 25 accent colors) |
| Mat Finish | Texture overlay: Eggshell (default), Flat, Linen, Suede, or Silk |
| Border Effect | Bevel (uniform inset border) or Shadow (drop shadow) |
| Bevel/Shadow Size | Effect width around images (0-16px) |
| Setting | Description |
|---|---|
| Slideshow Interval | Seconds between transitions (3-300, default 60) |
| Transition Duration | Fade animation length |
| Image Fit | "Contain" (full image) or "Cover" (fill screen) |
| Shuffle | Randomize photo order |
| Target Screen Ratio | 16:9, 21:9, 4:3, or 1:1 |
| TV Power Schedule | HDMI-CEC on/off times by day of week |
Mat color, mat finish, border effect, and image scale can be overridden per image or per group from the upload page preview. Image cropping is also available per image.
Control your TV's power automatically using HDMI-CEC. During install, choose "Enable HDMI-CEC TV power control" to set up the CEC device passthrough.
In the web UI settings panel:
- Click + Add Schedule to create a new on/off time pair
- Set the on time, off time, and select which days of the week
- Add multiple schedules for different viewing patterns
- Use Test On / Test Off buttons to verify CEC control works
- TV must support HDMI-CEC (most modern TVs do)
- Pi must be connected via HDMI
cec-utilsis installed automatically during setup- The CEC device (
/dev/cec0) must be passed through to the Docker container
- If CEC status shows "unavailable", ensure the device mapping is uncommented in
docker-compose.yml - Some TVs use different CEC brand names (Anynet+, Bravia Sync, SimpLink, etc.) — the protocol is the same
- Not all TVs respond to all CEC commands
When uploading photos, the app uses perceptual hashing (pHash) to detect potential duplicates:
- Each image gets a structural fingerprint that's compared against existing gallery images
- If a close match is found (Hamming distance < 10), you'll see a warning with a side-by-side comparison
- You can still choose to upload the image if desired
- Low-resolution images (below 1280x720) also trigger a warning
If you had photos uploaded before duplicate detection was added, run the backfill to compute hashes for existing images:
POST /api/gallery/backfill-hashes (Admin only)
The app supports cloud backup of your photos and settings to Dropbox via rclone.
- Navigate to
/backup(admin only) - Generate a Dropbox OAuth token and paste it into the configuration form
- The app writes an rclone config and verifies the connection
- Manual backup/restore — Run a backup or restore from the
/backuppage - Scheduled backups — Set a daily backup time (default: 3:00 AM)
- Custom path — Choose the Dropbox folder path for backups
- History — View past backup results on the
/backuppage - Disconnect — Remove the Dropbox connection at any time
rclonemust be installed in the Docker container (included in the Dockerfile)- A Dropbox account with an OAuth token
By default, the app uses a self-signed certificate (works immediately but shows a browser warning). For a trusted certificate with no warnings, the install script offers two DNS challenge options:
Best if you already own a domain managed by Cloudflare.
- During install, choose Let's Encrypt via Cloudflare
- Enter your domain (e.g.,
photos.example.com) - Enter your Cloudflare API token
- Enter your Cloudflare Zone ID (enables automatic DDNS updates)
Getting a Cloudflare API token:
- Go to Cloudflare API Tokens
- Click Create Token → use the Edit zone DNS template
- Configure the token:
- Permissions: Zone / DNS / Edit (pre-filled by the template)
- Zone Resources: Include → Specific zone → select your domain
- Client IP Address Filtering: Leave blank (optional — restrict which IPs can use the token)
- TTL: Leave blank for no expiration, or set an end date if you prefer rotating tokens
Zone ID is on your domain's Cloudflare overview page (right sidebar, under "API").
If you provide the Zone ID, the install script sets up a DDNS cron job that runs every 6 hours. It checks your public IP and automatically creates or updates the A record in Cloudflare — no need to manually manage DNS when your IP changes. Caddy will automatically obtain and renew the certificate.
Best if you don't own a domain. DuckDNS provides a free yourname.duckdns.org subdomain.
- During install, choose Let's Encrypt via DuckDNS
- Enter your DuckDNS subdomain (e.g.,
myframe) - Enter your DuckDNS token
Getting a DuckDNS token:
- Go to duckdns.org and sign in
- Create a subdomain
- Copy your token from the top of the page
You can re-run ./scripts/install.sh at any time to switch HTTPS modes. The script will update the Caddyfile and .env file, then rebuild the containers.
If you prefer to configure manually instead of using the install script:
- Copy
.env.exampleto.envand fill in your values - The install script generates the appropriate
Caddyfile, or you can edit it directly - Run
docker compose up -d --buildto apply changes
If you're using Let's Encrypt with a domain (Cloudflare or DuckDNS), you'll need to configure your router so external traffic reaches the Pi.
Forward these ports on your router to the Pi's local IP:
| Protocol | External Port | Internal Port | Destination |
|---|---|---|---|
| TCP | 80 | 80 | Pi's local IP (e.g., 192.168.1.68) |
| TCP | 443 | 443 | Pi's local IP |
The exact steps vary by router. Generally: log into your router's admin page, find Port Forwarding (sometimes under Firewall or NAT), and create rules for ports 80 and 443 pointing to the Pi.
Port forwarding rules target a specific local IP. If the Pi's IP changes (DHCP lease renewal), the rules break. Set up a DHCP reservation (also called a static lease) in your router to permanently assign the Pi's current IP to its MAC address. This is usually found near the DHCP settings in your router's admin page.
The Pi itself accesses the frame via localhost (the kiosk uses this automatically). For other devices on your local network, some routers support hairpin NAT and the domain will work from inside the LAN too. If your router doesn't, you have a few options:
-
By hostname — If your system runs Avahi/mDNS (default on Raspberry Pi OS), use
https://<hostname>.local(e.g.,https://raspberrypi.local) from other devices on the LAN. -
Via Tailscale — If Tailscale is installed, use the Tailscale IP (e.g.,
https://100.x.x.x) from any device on your Tailnet, regardless of network. -
By local IP — Access
https://192.168.1.68(your Pi's IP). This requires adding the IP to the Caddyfile's localhost block:localhost, 192.168.1.68 { tls internal reverse_proxy photo-frame:5000 }Then restart Caddy:
docker compose restart caddy. This uses a self-signed certificate (accept the browser warning once).
Tailscale creates a secure mesh VPN so you can access your photo frame from anywhere without opening ports on your router.
During install, choose "Install Tailscale for secure remote access". The script will:
- Install Tailscale
- Run
tailscale upto authenticate with your Tailscale account - Display your Tailscale IP address
- During install, the IP is printed to the terminal
- In the web UI, the admin settings panel shows both local and Tailscale IPs (after changing the default password)
- On the Pi:
tailscale ip -4
Set your PI_HOST secret to the Tailscale IP (e.g., 100.x.x.x). The GitHub Actions runner needs Tailscale access to reach the Pi — either install Tailscale on the runner or use a Tailscale subnet router.
Automated deployment via GitHub Actions. When you push to main, tests run and (if enabled) the update is deployed to your Pi.
-
In your GitHub repository, go to Settings > Secrets and variables > Actions
-
Add these secrets:
Secret Value PI_HOSTYour Pi's IP (Tailscale IP recommended) PI_USERSSH username (e.g., pi)PI_SSH_KEYPrivate SSH key for the Pi PI_SSH_PORTSSH port (optional, defaults to 22) -
Add this variable:
Variable Value DEPLOY_ENABLEDtrue
Deploys automatically check if the TV is currently scheduled to be on. If it is, the deploy is skipped to avoid interrupting the slideshow. The deploy will proceed on the next push when the TV is off.
This uses the /api/maintenance-window endpoint which checks TV schedules. If no schedules are configured, deploys always proceed.
SSH into the Pi and run:
cd ~/pi-photo-frame
./scripts/deploy.shThis also checks the maintenance window before proceeding.
pi-photo-frame/
├── app.py # Flask application
├── requirements.txt # Python dependencies
├── Dockerfile # Docker image definition
├── docker-compose.yml # Docker Compose config (app + Caddy)
├── Caddyfile # Caddy reverse proxy config (HTTPS)
├── .env.example # HTTPS configuration template
├── caddy/
│ └── Dockerfile # Custom Caddy build (DNS plugins)
├── scripts/
│ ├── install.sh # One-command setup script
│ ├── deploy.sh # Manual deploy script
│ ├── uninstall.sh # Complete removal script
│ ├── cloudflare-ddns.sh # DDNS updater (cron, every 6h)
│ └── restart-chromium.sh # Daily Chromium restart (cron)
├── .github/workflows/
│ └── deploy.yml # CI/CD pipeline (test + deploy)
├── tests/ # Test suite
├── uploads/ # Uploaded photos
│ └── thumbnails/ # Auto-generated 400px thumbnails
├── data/ # Settings, users, gallery data
│ ├── settings.json
│ ├── users.json
│ ├── gallery.json
│ └── .secret_key
└── templates/
├── login.html
├── upload.html
├── gallery.html
├── display.html
├── admin_users.html
├── backup.html
├── change_password.html
└── error.html
- Passwords are hashed with bcrypt
- Session keys are randomly generated
- HTTPS via Caddy (self-signed, Cloudflare, or DuckDNS Let's Encrypt)
- Secure cookies enabled behind the reverse proxy
- Display page accessible via token or localhost
- Non-root user in Docker container
The display (/display) is accessible:
- From localhost (the machine itself)
- With a valid display token
- When logged in
This allows the display to show photos without login while protecting upload/management.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/upload |
POST | User | Upload images |
/api/images |
GET | None | Get enabled images (for display) |
/api/gallery |
GET | User | Get all images with metadata |
/api/gallery/<file> |
PATCH | User | Update image metadata (scale, etc.) |
/api/gallery/<file> |
DELETE | User | Delete an image |
/api/gallery/bulk |
POST | User | Bulk enable/disable/delete |
/api/check-duplicates |
POST | User | Check files for duplicates before upload |
/api/gallery/backfill-hashes |
POST | Admin | Compute perceptual hashes for existing images |
/api/settings |
GET/POST | User (POST) | Get or update settings |
/api/tv-schedules |
GET/POST | User/Admin | Get or save TV power schedules |
/api/cec/status |
GET | User | Check if CEC is available |
/api/cec/test |
POST | Admin | Send test CEC command (on/standby) |
/api/network-info |
GET | Admin | Get local and Tailscale IP addresses |
/api/maintenance-window |
GET | None | Check if deploy is safe (TV off) |
/api/reorder |
POST | User | Reorder images |
/api/display-token |
GET | Admin | Get display access token |
/api/display/state |
GET | None | Get current slideshow state (index, paused) |
/api/display/control |
POST | User | Control slideshow (next, prev, pause, play) |
/api/groups |
GET/POST | User | List or create image groups |
/api/groups/<id> |
PATCH/DELETE | User | Update or delete an image group |
/api/backup/status |
GET | Admin | Get backup configuration status |
/api/backup/configure |
POST/DELETE | Admin | Connect or disconnect Dropbox |
/api/backup/run |
POST | Admin | Run a backup now |
/api/backup/restore |
POST | Admin | Restore from backup |
/api/backup/history |
GET | Admin | Get backup history |
/api/backup/settings |
POST | Admin | Update backup schedule/path |
/api/admin/users |
POST | Admin | Create user |
/api/admin/users/<user> |
DELETE | Admin | Delete user |
/api/admin/users/<user>/password |
POST | Admin | Reset password |
Delete the users file to reset to default:
docker compose exec photo-frame rm /app/data/users.json
docker compose restart- Check the gallery - photos might be hidden
- Ensure at least one photo is set to "visible"
- Check browser console for errors
- Ensure devices are on the same network
- Use the server's IP address if
.localhostname doesn't resolve - If using self-signed certificates, accept the browser warning
- If using Let's Encrypt, ensure your DNS points to the Pi's IP
To completely remove Pi Photo Frame (containers, images, volumes, cron jobs, and kiosk config):
cd ~/pi-photo-frame
./scripts/uninstall.shMIT License - feel free to modify and share!