Deploy Pinchflat on a VPS
A self-hosted YouTube media manager built on yt-dlp — single container, no Elasticsearch, drops cleanly into Plex, Jellyfin, or Kodi. Always-on archiving without leaving a desktop running.
At a Glance
| Project | kieraneglin/pinchflat |
| Stack | Phoenix/Elixir + yt-dlp + SQLite (WAL) |
| Recommended Plan | RamNode KVM 2 GB (small libraries); 4 GB if running alongside Jellyfin |
| OS | Ubuntu 24.04 LTS / Debian 12 |
| Reverse Proxy | Nginx with WebSocket headers (LiveView is ALL websocket) |
Storage planning
Decide upfront whether the VPS holds media long-term or acts as a download staging area that syncs to a NAS or object storage. Small VPS + nightly rclone to Backblaze B2 (or a home NAS over WireGuard) is usually cheaper at scale than perpetually upgrading the VPS disk.
Initial Server Hardening
adduser pinchflat
usermod -aG sudo pinchflat
rsync -a --chown=pinchflat:pinchflat ~/.ssh /home/pinchflat/PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yessystemctl reload ssh
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableOn 2 GB plans, add a 2 GB swap file as headroom for occasional yt-dlp memory spikes:
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstabInstall Docker
apt update
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
usermod -aG docker pinchflatLog out + back in, then verify with docker run --rm hello-world.
Plan Your Directory Layout
Two persistent directories: config (small — SQLite DB, logs, state) and downloads (large — all media). The container runs as UID 1000 by default.
mkdir -p /srv/pinchflat/{config,downloads}
chown -R 1000:1000 /srv/pinchflatDo NOT put config on a network share. Pinchflat uses SQLite WAL mode; SQLite on NFS or SMB is a recipe for database corruption. The downloads directory on a network share is fine. If you absolutely must, set JOURNAL_MODE=delete and accept the perf hit.
Deploy with Docker Compose
services:
pinchflat:
image: ghcr.io/kieraneglin/pinchflat:latest
container_name: pinchflat
restart: unless-stopped
ports:
- "127.0.0.1:8945:8945"
environment:
- TZ=America/New_York
- UMASK=022
# Uncomment if config dir is on a network share
# - JOURNAL_MODE=delete
volumes:
- ./config:/config
- ./downloads:/downloads
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8945/healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30scd /srv/pinchflat
docker compose up -d
docker compose logs -fCritical: port binding is 127.0.0.1:8945:8945, not 8945:8945 — Pinchflat ships without authentication. Never expose it directly. TZ matters because the scheduler uses local time for "check every X hours" rules.
Nginx with WebSocket Support + Let's Encrypt
apt install -y nginx certbot python3-certbot-nginxPinchflat's UI runs entirely over Phoenix LiveView WebSockets. A proxy that doesn't forward Upgrade + Connection headers gives you a UI that loads but never updates.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name pinchflat.example.com;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pinchflat.example.com;
ssl_certificate /etc/letsencrypt/live/pinchflat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pinchflat.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 64M;
proxy_buffering off;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
location / {
proxy_pass http://127.0.0.1:8945;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Critical for Phoenix LiveView
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}ln -s /etc/nginx/sites-available/pinchflat /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl reload nginx
certbot --nginx -d pinchflat.example.com
certbot renew --dry-runLock Down the UI with Authentication
Option A — Pinchflat's built-in basic auth (newer versions). Add to compose env:
environment:
- TZ=America/New_York
- UMASK=022
- BASIC_AUTH_USERNAME=admin
- BASIC_AUTH_PASSWORD=use_a_long_random_string_hereOption B — Nginx-level basic auth (works for older versions, or if you want the auth boundary at the edge):
apt install -y apache2-utils
htpasswd -c /etc/nginx/.htpasswd admin
chown root:www-data /etc/nginx/.htpasswd
chmod 640 /etc/nginx/.htpasswdauth_basic "Pinchflat";
auth_basic_user_file /etc/nginx/.htpasswd;First-Run Configuration
- Set the media directory:
/downloadsinside the container maps to/srv/pinchflat/downloadson the host. - Pick a media profile: Plex/Jellyfin presets generate directory layouts those servers pick up automatically.
- Add your first source: channel or playlist URL. "Fast indexing" uses YouTube's RSS feed (capped at the 15 most recent videos); full indexing backfills.
- Verify the schedule: stagger sources or lengthen the interval if you have many to avoid YouTube rate limits.
YouTube Cookies + Plex/Jellyfin Integration
Cookies for age-gated, members-only, or private content — Pinchflat reads /config/cookies.txt in Netscape format. Use the Get cookies.txt LOCALLY Chrome extension on a fresh browser profile (don't reuse it for normal browsing — Pinchflat traffic patterns can lock the account).
scp cookies.txt pinchflat@your-vps-ip:/srv/pinchflat/config/cookies.txt
ssh pinchflat@your-vps-ip "sudo chown 1000:1000 /srv/pinchflat/config/cookies.txt && sudo chmod 600 /srv/pinchflat/config/cookies.txt"Bot detection: if you hit "Sign in to confirm you're not a bot" — use the cookies file even for non-restricted content, lower concurrency in your profile, and update yt-dlp regularly. Plex/Jellyfin: point the media server at /srv/pinchflat/downloads over NFS/SMB/Syncthing. Plex/Jellyfin presets generate .nfo files + season/episode filenames automatically.
Backups
Config (small, critical): SQLite DB, profiles, source list, schedule. Lose this and you lose your library configuration. Back up daily with multiple versions.
#!/bin/bash
set -euo pipefail
TS=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR=/srv/pinchflat/backups
mkdir -p "$BACKUP_DIR"
# SQLite online backup via the container
docker exec pinchflat sqlite3 /config/db/pinchflat.db ".backup /config/db/backup.db"
tar -czf "$BACKUP_DIR/config-$TS.tar.gz" -C /srv/pinchflat config
rm -f /srv/pinchflat/config/db/backup.db
find "$BACKUP_DIR" -name "config-*.tar.gz" -mtime +14 -deletechmod +x /usr/local/bin/pinchflat-backup.sh
echo "30 4 * * * root /usr/local/bin/pinchflat-backup.sh" > /etc/cron.d/pinchflat-backupDownloads (large, regenerable): usually "don't bother — Pinchflat will redownload from YouTube". If source content is at risk of disappearing, mirror to S3 with rclone sync.
Updates
cd /srv/pinchflat
docker compose pull
docker compose up -d
docker image prune -fyt-dlp ships inside each Pinchflat release, so updating regularly matters for keeping up with YouTube API changes. For production, pin to a tagged release rather than :latest.
Monitoring
Health check at /healthcheck; Prometheus metrics at /metrics (set PROMETHEUS_METRICS_ENABLED=true). For single-node, an UptimeRobot or Healthchecks.io ping is enough.
#!/bin/bash
THRESHOLD=90
USAGE=$(df /srv/pinchflat/downloads | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$USAGE" -gt "$THRESHOLD" ]; then
echo "Pinchflat downloads volume at ${USAGE}% on $(hostname)" | \
mail -s "Pinchflat disk warning" you@example.com
fiA full disk silently breaks downloads with confusing errors — this catches it.
Troubleshooting
- "Permission denied" in container logs: mounted host dirs not writable by UID 1000 —
chown -R 1000:1000 /srv/pinchflat/config /srv/pinchflat/downloads. - UI loads but never updates / WebSocket close errors: reverse proxy not forwarding upgrade headers. Re-check the
map+Upgrade/Connectionblocks. - "Sign in to confirm you're not a bot": YouTube rate-limiting the VPS IP. Add a cookies file + reduce concurrent downloads.
- DB errors after unclean shutdown: SQLite WAL on a network filesystem. Move config to local storage or set
JOURNAL_MODE=delete; restore from backup if corrupted. - Plex/Jellyfin doesn't see new files: check ownership inside
/srv/pinchflat/downloadsis readable by the media server user, and that the server has an inotify watcher or scheduled scan.
