让家里的 Jellyfin 在外面也能看,同时尽量少开端口。这里有两条路:只给自己和家人用的话,用 Tailscale Serve,只有你 tailnet 里的设备能访问,不需要开放任何端口;需要用域名访问时,在前面加一个 Caddy 反向代理负责 HTTPS,并且在 Jellyfin 里配置 Known Proxies,否则 Jellyfin 看不到客户端的真实 IP。
先看清成品
- Caddy: 在 box 上用 Docker 实测了 Caddy + Jellyfin:
caddy validate显示 Valid configuration。- 用
tls internal的自签证书,带上 Caddy 根证书访问返回 200;不带根证书时,curl 报证书错误(退出码 60)。- Known Proxies: 没配置时,Jellyfin 看到的客户端 IP 是 172.25.0.3(Caddy 自己)。配置并重启后,变成了 172.25.0.1(真实的来源地址)。
- Tailscale: 命令按官方文档整理,没有实测(box 上没有 Tailscale 账号)。
- 公网域名和 Let's Encrypt 证书也没有测过。
注意:Jellyfin 是免费开源的软件,硬件转码也不收费。媒体库里只放你有权使用的内容;不要安装或分享盗版插件和资源,不要把服务器开放给家庭以外的人共享内容。本文的测试素材都是用 ffmpeg 生成的测试图样和正弦波。
安全提醒:不要把 8096 端口直接映射到公网。家庭用途优先选 Tailscale Serve;Funnel 会把服务公开到整个互联网,一般不需要。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
Caddyfile |
tls internal 的局域网版本;换成域名就能自动申请证书 |
docker-compose.yml |
Jellyfin + Caddy,给 Caddy 固定 IP,Known Proxies 才不会失效 |
proxy_check.py |
检查 Jellyfin 看到的客户端 IP,并且可以修改 Known Proxies |
tailscale.sh |
Serve 的开启、查看、关闭命令 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 方案一 | Tailscale 账号,服务器和手机都装上客户端 | 不需要开放端口 |
| 方案二 | 自己的域名,80/443 端口能从公网访问 | Caddy 会自动申请证书 |
| Python | 3.8 以上 | pip install requests |
文件结构
38-jellyfin-C-src/
├── Caddyfile
├── docker-compose.yml
├── proxy_check.py
└── tailscale.sh
方案一:Tailscale Serve(推荐给家庭使用)
tailscale.sh:
# 不开放端口的远程访问:Tailscale Serve(只有自己 tailnet 里的设备能访问)
# 在运行 Jellyfin 的机器上,安装 Tailscale 并登录以后:
tailscale serve --bg 8096 # 在后台运行,https://<机器名>.<tailnet>.ts.net 会转发到本机 8096
tailscale serve status # 查看当前在提供哪些服务
tailscale serve reset # 关闭并清空全部 serve 配置(也可以在原来的命令末尾加 off,只关掉那一条)
# Funnel 会把服务公开到互联网(只能用 443、8443、10000 端口)。家庭媒体库一般不需要,不建议打开
官方文档说明:--bg 表示在后台运行;Funnel 只能监听 443、8443、10000 三个端口,而且只能用 tailnet 自己的 ts.net 域名。
方案二:Caddy 反向代理
Caddyfile:
# 局域网 / Tailscale 内使用:Caddy 自签证书(tls internal),客户端需要信任 Caddy 的根证书,或者在测试时用 curl -k
# 有公网域名时,把站点名换成你的域名,并删掉 tls internal,Caddy 会自动申请证书(需要 80/443 端口可以从公网访问)
jellyfin.home.arpa {
tls internal
reverse_proxy jellyfin:8096
}
docker-compose.yml:
# Jellyfin + Caddy。给 Caddy 固定 IP,Known Proxies 才不会因为容器重启、IP 变化而失效
services:
jellyfin:
image: jellyfin/jellyfin
user: "1000:1000"
volumes:
- ./config:/config
- ./cache:/cache
- /srv/media:/media:ro
networks: [media]
restart: unless-stopped
caddy:
image: caddy:2
ports: ["443:443", "80:80"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
networks:
media: { ipv4_address: 172.25.0.3 }
restart: unless-stopped
networks:
media:
ipam: { config: [{ subnet: 172.25.0.0/24 }] }
$ docker run --rm -v $PWD/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2 caddy validate --config /etc/caddy/Caddyfile
Valid configuration
$ docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy-root.crt
$ curl --cacert caddy-root.crt https://jellyfin.home.arpa/System/Info/Public
{"LocalAddress":"http://172.25.0.2:8096","ServerName":"…","Version":"12.2.0",…}
配置 Known Proxies
proxy_check.py:
"""检查反向代理后面的 Jellyfin 能不能看到客户端的真实 IP(Known Proxies 设置有没有生效)。
Jellyfin 只信任“已知代理”发来的 X-Forwarded-For;没配置时,所有访问都会显示成代理自己的 IP,
“只允许局域网访问”之类基于 IP 的限制也就跟着失效了。
用法:JF_PUBLIC=https://你的域名 JF_PASSWORD=... python proxy_check.py [--ca 根证书.pem]
python proxy_check.py --set-proxy 172.25.0.3 (需要管理员身份,修改 Known Proxies)"""
import os, sys
import requests
BASE = os.environ.get("JF_PUBLIC", "https://jellyfin.home.arpa").rstrip("/")
CA = sys.argv[sys.argv.index("--ca") + 1] if "--ca" in sys.argv else True
DEV = "proxy-check-1"
AUTH = f'MediaBrowser Client="proxy_check", Device="script", DeviceId="{DEV}", Version="1.0"'
def login():
r = requests.post(BASE + "/Users/AuthenticateByName", json={"Username": os.environ.get("JF_ADMIN", "admin"), "Pw": os.environ["JF_PASSWORD"]},
headers={"Authorization": AUTH}, verify=CA, timeout=15)
r.raise_for_status(); return r.json()["AccessToken"]
def h(tok): return {"Authorization": AUTH + f', Token="{tok}"'}
if __name__ == "__main__":
tok = login()
if "--set-proxy" in sys.argv:
ip = sys.argv[sys.argv.index("--set-proxy") + 1]
c = requests.get(BASE + "/System/Configuration/network", headers=h(tok), verify=CA, timeout=15).json()
c["KnownProxies"] = [ip]
requests.post(BASE + "/System/Configuration/network", json=c, headers=h(tok), verify=CA, timeout=15).raise_for_status()
print(f"✓ Known Proxies = {[ip]}(本教程实测时重启了 Jellyfin 再检查;改完不确定是否生效时,就重启一次)")
else:
s = [x for x in requests.get(BASE + "/Sessions", headers=h(tok), verify=CA, timeout=15).json() if x.get("DeviceId") == DEV]
print("Jellyfin 看到的客户端 IP:", s[0].get("RemoteEndPoint") if s else "(没有找到会话)")
$ python proxy_check.py --ca caddy-root.crt
Jellyfin 看到的客户端 IP: 172.25.0.3
$ python proxy_check.py --ca caddy-root.crt --set-proxy 172.25.0.3
✓ Known Proxies = ['172.25.0.3'](本教程实测时重启了 Jellyfin 再检查;改完不确定是否生效时,就重启一次)
$ docker restart jellyfin && python proxy_check.py --ca caddy-root.crt
Jellyfin 看到的客户端 IP: 172.25.0.1
检查点:配置之后,Jellyfin 里显示的客户端 IP 是真实地址,不再是代理的 IP。官方文档说明:如果没有配置,基于 IP 的“仅限本地网络访问”这类限制就会失效。
调试示例:所有访问都“来自”代理
刚接上 Caddy 的时候,proxy_check.py 显示所有访问都来自 172.25.0.3,也就是 Caddy 容器自己的 IP。官方文档解释得很清楚:Jellyfin 只信任 Known Proxies 列表里的代理发来的 X-Forwarded-For,其余的一律丢弃。把 Caddy 的 IP 加进 Known Proxies 并重启以后,显示变成了 172.25.0.1。第二个坑是:容器每次重启,IP 可能会变,所以 compose 里给 Caddy 固定了 ipv4_address。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 浏览器提示证书不受信任 | 用的是 tls internal 自签证书 |
在设备上导入 Caddy 根证书,或者改用公网域名 |
| 客户端 IP 全是代理的 | 没有配置 Known Proxies | 按上面的步骤配置 |
| 用了 Funnel 之后被陌生人访问 | Funnel 是公开到互联网的 | 改回 Serve |
| 手机 App 连不上 | 没有填 https 地址 | 填完整的 https:// 地址 |
模板规则
- 能用 Tailscale Serve,就不要开放公网端口。
- 用反向代理时一定配置 Known Proxies,并且给代理固定 IP。
- 证书交给 Caddy 自动管理,不要把 DNS 服务商的 API 密钥写进 Caddyfile(官方文档也提醒了这一点)。
项目资源
| 用途 | 地址 |
|---|---|
| 反向代理(官方) | https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/ |
| Caddy 配置(Jellyfin 官方) | https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/caddy/ |
| Tailscale Serve | https://tailscale.com/kb/1312/serve |
| tailscale serve 命令 | https://tailscale.com/kb/1242/tailscale-serve |
| Tailscale Funnel | https://tailscale.com/kb/1223/funnel |
| Caddy reverse_proxy | https://caddyserver.com/docs/caddyfile/directives/reverse_proxy |
| Jellyfin 文档首页 | https://jellyfin.org/docs/ |
远程访问要做到“能用”又“不裸奔”:家人自己用就走 Tailscale;要用域名就让 Caddy 负责 HTTPS,并且把 Known Proxies 配好。