Building a Self-Hosted Media Server with Jellyfin, Docker, and a Reverse Proxy
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Jellyfin is the best free, open-source alternative to Plex and Emby — no account required, no mandatory subscription, and no phone-home telemetry. When I set this up on my home server a few years ago, I immediately ditched my Plex Pass and never looked back. In this tutorial I'll walk you through deploying Jellyfin using Docker Compose, fronting it with a Caddy reverse proxy for automatic HTTPS, and hardening the setup so it's safe to expose to the internet.
What You'll Need
You can run this on a spare PC, a Raspberry Pi 4/5, a NAS with Docker support, or a VPS. I personally run it on a small Intel N100 mini-PC with 16 GB RAM and a 4 TB external drive — total cost under $200. If you'd rather host on a cloud droplet to get reliable uptime and easy bandwidth, DigitalOcean Droplets start at $6/month and make a great remote Jellyfin host. You'll also need:
- Docker and Docker Compose installed (v2 plugin syntax, so
docker composenotdocker-compose) - A domain name (or a free subdomain via DuckDNS) pointing to your server's public IP
- Ports 80 and 443 open on your firewall/router
- Your media files already organised on the host filesystem
Directory Structure and Environment Setup
I like to keep all my Docker projects under /opt/stacks/. Create the working directory and a place for Jellyfin to store its configuration and cache:
sudo mkdir -p /opt/stacks/jellyfin
cd /opt/stacks/jellyfin
# Jellyfin needs writable dirs for its database and transcodes
sudo mkdir -p data/config data/cache
sudo chown -R 1000:1000 data/
Replace 1000:1000 with your own UID/GID if different — run id in your terminal to check.
The Docker Compose File
I prefer Caddy as the reverse proxy because it handles Let's Encrypt certificate issuance and renewal completely automatically — no Certbot cron jobs, no manual renewal, nothing to babysit. The compose file below spins up both Jellyfin and Caddy in one shot. Drop this into /opt/stacks/jellyfin/docker-compose.yml:
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
user: "1000:1000"
network_mode: host # simplest for hardware transcoding; swap to bridge if you prefer
volumes:
- ./data/config:/config
- ./data/cache:/cache
- /mnt/media/movies:/media/movies:ro
- /mnt/media/tv:/media/tv:ro
restart: unless-stopped
environment:
- JELLYFIN_PublishedServerUrl=https://jellyfin.yourdomain.com
caddy:
image: caddy:2-alpine
container_name: caddy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
network_mode: host
volumes:
caddy_data:
caddy_config:
Adjust the two /mnt/media/... paths to wherever your actual media lives. I mount them read-only (:ro) so Jellyfin cannot accidentally modify or delete source files.
network_mode: host means both containers share the host network stack. This is the easiest path to hardware transcoding (iGPU, NVENC, etc.) but it bypasses Docker's network isolation. If you're running on a shared VPS or want stricter isolation, switch both services to a custom bridge network and adjust the Caddy upstream to http://jellyfin:8096 instead of http://localhost:8096.Writing the Caddyfile
Create /opt/stacks/jellyfin/Caddyfile. Caddy will automatically obtain and renew a TLS certificate from Let's Encrypt as long as your DNS is pointing correctly and port 80 is reachable:
jellyfin.yourdomain.com {
reverse_proxy localhost:8096 {
# Jellyfin needs these headers for proper client detection
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
# Recommended security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Increase timeouts for large file streaming
request_body {
max_size 50GB
}
encode gzip zstd
log {
output file /var/log/caddy/jellyfin.log {
roll_size 10mb
roll_keep 5
}
}
}
Replace jellyfin.yourdomain.com with your actual hostname. If you're testing locally without a domain, you can temporarily use an IP or swap to Caddy's self-signed TLS with tls internal.
Bringing It All Up
Start the stack and tail the logs to confirm everything initialises cleanly:
cd /opt/stacks/jellyfin
docker compose up -d
# Watch Caddy obtain the certificate (takes ~10-30 seconds on first run)
docker compose logs -f caddy
# Watch Jellyfin start up
docker compose logs -f jellyfin
Once you see Caddy log something like certificate obtained successfully, navigate to https://jellyfin.yourdomain.com in a browser and you'll land on the first-run wizard. Walk through it: choose your language, create an admin account, and add your media libraries by pointing Jellyfin at /media/movies and /media/tv — those are the container-side paths we mapped in the compose file.
Enabling Hardware Transcoding
Software transcoding on a low-power machine will bottleneck quickly. Jellyfin supports Intel Quick Sync (VA-API), AMD AMF, and NVIDIA NVENC natively. For Intel iGPU (which covers most NUCs and N100 boxes), you need to expose /dev/dri to the container. Add this to the jellyfin service in your compose file:
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
group_add:
- render # or the numeric GID of the render group: run `getent group render`
Then in the Jellyfin admin dashboard go to Dashboard → Playback → Transcoding, set the hardware acceleration to Video Acceleration API (VA-API), and point the device path to /dev/dri/renderD128. Restart the container with docker compose restart jellyfin and you should see GPU utilisation spike when a transcode is triggered.
Firewall Rules
If you're using UFW (common on Ubuntu and Debian), make sure only the ports Caddy needs are open. Jellyfin's port 8096 should not be publicly accessible — Caddy handles all inbound traffic:
# Allow HTTP and HTTPS through to Caddy
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp # HTTP/3
# Block direct access to Jellyfin's port from outside
# (if not using network_mode host, Jellyfin's port won't be published anyway)
sudo ufw deny 8096/tcp
sudo ufw reload
sudo ufw status verbose
Keeping Everything Updated
I use Watchtower in monitor-only mode to get Slack/email notifications when new Jellyfin images are available, then I do the actual pull manually to avoid surprise restarts mid-stream. Add this to your compose file or run it as a separate stack:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower_jellyfin
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_MONITOR_ONLY=true
- WATCHTOWER_NOTIFICATIONS=email
- [email protected]
restart: unless-stopped
When you're ready to update manually: docker compose pull && docker compose up -d. Jellyfin's config directory persists in ./data/config so your libraries, users, and settings survive the upgrade.
Optional: Hosting Jellyfin on a VPS
If you want your media accessible with good upload speeds regardless of your home ISP, a cloud Droplet is a solid option. DigitalOcean Droplets offer 99.99% SLA uptime and predictable monthly pricing — the 2 vCPU / 4 GB RAM / 80 GB SSD tier at $18/month is enough to run Jellyfin for a few concurrent streams if you're serving pre-transcoded or Direct Play compatible files. Keep in mind bandwidth costs if you're streaming 4K; consider pre-converting files to H.264 to minimise server-side transcoding on a VPS.
Wrapping Up
At this point you have a fully working Jellyfin media server running in Docker, served over HTTPS with automatic certificate management via Caddy, and locked down so the raw Jellyfin port is never publicly exposed. The whole stack can be version-controlled in a Git repo, backed up with a simple tar of the data/config directory, and torn down and redeployed in under five minutes.
From here, the two most useful next steps are: adding a second factor of authentication with Authelia in front of Jellyfin, and setting up Uptime Kuma to alert you if the service goes down. Happy streaming.
Discussion