Locking down Jellyfin/Emby on the open internet with Traefik IP allow-listing

TL;DR: Putting Traefik in front and allow only specific IPs to reach your media server. You get valid HTTPS, clean app logins, and a tiny public attack surface and took me like 5 min.

Why this approach?

  • I’m lazy and I only give this type of stuff out to people I can trust.
  • Also it just works.

What you’ll build

  • Traefik terminates HTTPS on ports 80/443.
  • Only your allow-listed IPs can reach media.example.com.
  • Jellyfin/Emby still shows its normal sign-in (no SSO).
  • No publicly exposed admin dashboards.

Prereqs

  • Docker & Docker Compose installed.
  • A domain with a DNS A/AAAA record (e.g., media.example.com) pointing to your box.
  • Port-forward 80/tcp and 443/tcp from your router to the Traefik host (do not forward the Jellyfin/Emby app port).

1) Create the shared network

docker network create traefik-public

2) Prepare Let’s Encrypt storage

mkdir -p ./letsencrypt
touch ./letsencrypt/acme.json
chmod 600 ./letsencrypt/acme.json

3) Docker Compose

Replace:

  • media.example.com with your domain
  • admin@example.com with your ACME email
  • The CIDRs under ipallowlist.sourcerange with your approved IPs (use IPv4/IPv6 as needed)
version: "3.8"

networks:
  traefik-public:
    external: true  # run: docker network create traefik-public

services:

  traefik:
    image: traefik:v3.4
    container_name: traefik
    restart: unless-stopped
    command:
      - "--log.level=INFO"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"

      # Dashboard/API in "secure" mode only (we'll route it with a whitelist below)
      - "--api.dashboard=true"

      # Let's Encrypt (HTTP challenge via entrypoint web:80)
      - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./letsencrypt:/letsencrypt"
    networks:
      - traefik-public
    labels:
      - "traefik.enable=true"

      # (Optional) Secure dashboard at https://traefik.example.com (allow-listed)
      - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls=true"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=allow-my-ips@docker"

  # A tiny helper container that only exists to define reusable middlewares.
  allowlist:
    image: alpine:latest
    container_name: allowlist
    command: ["sleep", "infinity"]
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      # 👇 Replace with your actual permitted IPs or CIDRs (examples below are RFC TEST-NET)
      - "traefik.http.middlewares.allow-my-ips.ipallowlist.sourcerange=203.0.113.0/24,198.51.100.0/24"

  # Use emby/embyserver:latest or swap to jellyfin/jellyfin:latest — labels are the same idea.
  media:
    image: emby/embyserver:latest
    container_name: media
    restart: unless-stopped
    networks:
      - traefik-public
    volumes:
      - "/path/to/your/media:/media:ro"
    # Optional local access without TLS (LAN only). Remove if you don't need it.
    ports:
      - "8096:8096"
    labels:
      - "traefik.enable=true"

      # HTTP -> HTTPS redirect
      - "traefik.http.routers.media-http.rule=Host(`media.example.com`)"
      - "traefik.http.routers.media-http.entrypoints=web"
      - "traefik.http.routers.media-http.middlewares=redirect-to-https"
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"

      # HTTPS router with IP allow-list and Let's Encrypt
      - "traefik.http.routers.media-https.rule=Host(`media.example.com`)"
      - "traefik.http.routers.media-https.entrypoints=websecure"
      - "traefik.http.routers.media-https.tls=true"
      - "traefik.http.routers.media-https.tls.certresolver=letsencrypt"
      - "traefik.http.routers.media-https.middlewares=allow-my-ips@docker"

      # Service port (Emby/Jellyfin inside the container)
      - "traefik.http.services.media.loadbalancer.server.port=8096"

4) DNS & router

  • Point media.example.com → your WAN IP (DNS “A” record; add AAAA for IPv6 if you’ll allow-list v6).
  • Forward 80 and 443 to the Traefik host. Do not expose 8096 directly to the internet.

5) Start it up

docker compose up -d

Visit https://media.example.com from an allow-listed IP. You should see the Jellyfin/Emby sign-in as usual.


Managing the allow-list

  • Edit the middleware label on the allowlist container: traefik.http.middlewares.allow-my-ips.ipallowlist.sourcerange=203.0.113.0/24,198.51.100.10/32 …then docker compose up -d to apply. You can list as many CIDRs and single IPs as you need.
    • I also have portainer so when im on the move i just vpn back home and modify the env an then restart it and boom done.
  • Traveling a lot or on mobile data with changing IPs? This strategy gets annoying. At that point, a tunnel/VPN (Tailscale, etc.) is actually the ergonomic answer—even if you don’t want it today.

Optional hardening

  • Rate limiting to blunt brute-force and scrapers (Traefik RateLimit middleware).
  • CrowdSec / WAF at the edge if you later move away from strict allow-listing.
  • Separate dashboard hostname (traefik.example.com) and keep it on the same allow-my-ips middleware (already in the compose).
  • HSTS and modern TLS ciphers (Traefik supports custom TLS options).

Leave a Reply

Your email address will not be published. Required fields are marked *

© 2025 TooBrokeToQuit